Compare commits
18
Commits
b420aa4c9d
...
280204db18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
280204db18
|
||
|
|
5d5456fa68
|
||
|
|
bb245a90a3
|
||
|
|
9ed732e49e
|
||
|
|
80e725eb9a
|
||
|
|
1387afe94f
|
||
|
|
1f4267a046
|
||
|
|
2ed9c9020f
|
||
|
|
512567c8ba
|
||
|
|
b3d7c08f4a
|
||
|
|
322bd8aa5b
|
||
|
|
aac3ced262
|
||
|
|
3d3448d050
|
||
|
|
e7fe88a986
|
||
|
|
3df7f88fdc
|
||
|
|
5075f6b116
|
||
|
|
37f2f6481a
|
||
|
|
b808ceff25
|
@@ -124,9 +124,12 @@ Module path — `git.vakhrushev.me/av/jellybit`. Go 1.26, `CGO_ENABLED=0`.
|
|||||||
файл (`config.toml` не коммитится, `0600`), не в env; валидация на старте:
|
файл (`config.toml` не коммитится, `0600`), не в env; валидация на старте:
|
||||||
[docs/conventions/config.md](docs/conventions/config.md).
|
[docs/conventions/config.md](docs/conventions/config.md).
|
||||||
- Время — всегда с явным TZ (сервер в `Europe/Moscow`).
|
- Время — всегда с явным TZ (сервер в `Europe/Moscow`).
|
||||||
- Миграции БД (goose, `internal/store/migrations`) — при изменении структуры
|
- Идентификаторы — TEXT ULID (lowercase) через `internal/ident`, без числовых
|
||||||
(таблица/столбец/индекс/связь) в том же change обновляем ER-схему
|
AUTOINCREMENT; внешние id валидируются `ident.Parse` на границе:
|
||||||
[docs/specs/database.md](docs/specs/database.md).
|
[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.
|
[docs/conventions/](docs/conventions/README.md) и не переносятся в OpenSpec.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
_ "time/tzdata" // встроенная база zoneinfo: general.timezone работает на любом хосте
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ func runServe(args []string) error {
|
|||||||
logger.Info("jellyfin rescan enabled", "url", cfg.Jellyfin.URL)
|
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{
|
router, err := httpapi.NewRouter(httpapi.Deps{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
Ingestor: ingestor,
|
Ingestor: ingestor,
|
||||||
@@ -155,6 +159,7 @@ func runServe(args []string) error {
|
|||||||
Reader: st,
|
Reader: st,
|
||||||
Reviewer: wrk,
|
Reviewer: wrk,
|
||||||
Live: wrk,
|
Live: wrk,
|
||||||
|
Loc: loc,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -184,7 +189,7 @@ func runServe(args []string) error {
|
|||||||
}, logger)
|
}, logger)
|
||||||
wrk.SetNotifier(bot)
|
wrk.SetNotifier(bot)
|
||||||
// Приёмные падения (qbit_add) минуют worker — уведомляем напрямую.
|
// Приёмные падения (qbit_add) минуют worker — уведомляем напрямую.
|
||||||
ingestor.SetFailureNotifier(func(id int64) {
|
ingestor.SetFailureNotifier(func(id string) {
|
||||||
bot.Notify(context.Background(), id, worker.EventFailed)
|
bot.Notify(context.Background(), id, worker.EventFailed)
|
||||||
})
|
})
|
||||||
go bot.Run(ctx)
|
go bot.Run(ctx)
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
# config.toml в рабочей директории; путь переопределяется опцией --config=path.
|
# config.toml в рабочей директории; путь переопределяется опцией --config=path.
|
||||||
# Для локального запуска укажите существующие каталоги и db_path -> ./jellybit.db.
|
# Для локального запуска укажите существующие каталоги и db_path -> ./jellybit.db.
|
||||||
|
|
||||||
|
[general]
|
||||||
|
# Общие настройки приложения.
|
||||||
|
timezone = "UTC" # таймзона ОТОБРАЖЕНИЯ времени в веб-UI (IANA, напр. "Europe/Moscow"); хранение всегда в UTC. Пусто → UTC
|
||||||
|
|
||||||
[qbittorrent]
|
[qbittorrent]
|
||||||
url = "http://qbit:8989" # адрес qBittorrent WebUI; в docker-сети — по имени сервиса
|
url = "http://qbit:8989" # адрес qBittorrent WebUI; в docker-сети — по имени сервиса
|
||||||
username = "admin" # логин WebUI
|
username = "admin" # логин WebUI
|
||||||
|
|||||||
+183
-117
@@ -14,17 +14,54 @@ _(идея)_ — их сперва надо проработать.
|
|||||||
### Проблема второго сезона
|
### Проблема второго сезона
|
||||||
|
|
||||||
Если первый сезон сериала уже разложен, а мы добавляем второй/третий/…,
|
Если первый сезон сериала уже разложен, а мы добавляем второй/третий/…,
|
||||||
распознавание должно привязать новый сезон к **тому же** названию и папке,
|
новый сезон должен лечь в **ту же** папку сериала, а не завести рядом почти
|
||||||
а не завести рядом почти одинаковую вторую папку. Ключ — стабильный
|
одинаковую вторую. Разбор ([drafts/logical-title-model.md](drafts/logical-title-model.md))
|
||||||
`provider_id`: один и тот же `[tvdbid-…]` → одна папка сериала, новые
|
показал: проблема не в группировке, а в **сходимости папки** — папка каждый
|
||||||
`Season NN` доливаются внутрь. Нужно: при матче учитывать уже существующие
|
раз печатается заново из выхода LLM, и совпадение `provider_id` не
|
||||||
в библиотеке сериалы (или прошлые распознавания с тем же провайдер-id) и
|
гарантирует совпадение строки («Fargo» vs «Фарго», год сезона vs год
|
||||||
склонять LLM/выбор кандидата к согласованности с ними.
|
сериала). Отдельная сущность «тайтл» **не вводится**; решение — правило
|
||||||
|
сходимости при построении плана: при подтверждённом матче наследовать базу
|
||||||
|
папки от живых `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) (папка
|
матч в базе), [jellyfin-layout.md](specs/jellyfin-layout.md) (папка
|
||||||
сериала с провайдер-id).
|
сериала с провайдер-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)
|
### Удаление средствами jellybit («единое окно», path 2)
|
||||||
|
|
||||||
Распознавание **ручного** удаления (источник из qBittorrent / цель из
|
Распознавание **ручного** удаления (источник из qBittorrent / цель из
|
||||||
@@ -35,68 +72,29 @@ preflight перед действиями. См. `openspec/specs/state-reconcili
|
|||||||
[workflow.md](specs/workflow.md) → «Сверка с реальностью».
|
[workflow.md](specs/workflow.md) → «Сверка с реальностью».
|
||||||
|
|
||||||
Осталось (path 2) — продолжение «единого окна»: удалять просмотренное
|
Осталось (path 2) — продолжение «единого окна»: удалять просмотренное
|
||||||
**из самого jellybit**, не идя руками в qBittorrent/Jellyfin. Нужно
|
**из самого jellybit**, не идя руками в qBittorrent/Jellyfin. Решения из
|
||||||
продумать: команду удаления (снять наши хардлинки + опц. удалить раздачу из
|
разбора ([drafts/logical-title-model.md](drafts/logical-title-model.md)
|
||||||
qBittorrent с файлами), подтверждение осознанности (а не случайный клик) и
|
§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) → «Раскладка файлов»,
|
[architecture.md](specs/architecture.md) → «Раскладка файлов»,
|
||||||
[workflow.md](specs/workflow.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`), их попытки
|
Терминальные задачи (`done`/`cancelled`/`failed`/`reverted`), их попытки
|
||||||
@@ -167,21 +165,23 @@ qBittorrent, пул LLM-вызовов и запись в SQLite спроект
|
|||||||
[docs/conventions](conventions/README.md),
|
[docs/conventions](conventions/README.md),
|
||||||
[«Словарь единого языка»](#словарь-единого-языка-ubiquitous-language).
|
[«Словарь единого языка»](#словарь-единого-языка-ubiquitous-language).
|
||||||
|
|
||||||
### Автогенерируемый идентификатор загрузки (ULID/UUID)
|
### Сила совпадения кандидата и пересмотр распознавания/матчинга _(идея)_
|
||||||
|
|
||||||
Сейчас загрузка фактически идентифицируется хешем торрента (`infohash`). Это
|
Сейчас у кандидата метабазы нет метрики силы совпадения (`metadata_candidate`
|
||||||
хрупко: у одной логической загрузки может быть **несколько** хешей
|
хранит provider/id/title/year/url), а решение «авто vs review» — по правилу
|
||||||
(перезаливы, докачивание, репаки, v1/v2 infohash), и привязка домена к хешу
|
«единственный сильный матч + валидация», не по числовой уверенности. Для
|
||||||
мешает слиянию и истории. Ввести собственный стабильный идентификатор
|
ревью это значит: список кандидатов нечем отсортировать/подсветить по
|
||||||
(ULID/UUID), генерируемый при приёме, как первичный ключ домена;
|
уверенности — берём порядок сбора. Идея — ввести на этапе матча **силу
|
||||||
`infohash`(ы) — отдельный атрибут/таблица «многие к одному», по которому
|
совпадения кандидата** (точное совпадение названия+года vs частичное) для
|
||||||
**остаётся** поиск и дедуп для обратной совместимости. Enabler для
|
сортировки и подсказки в UI. Шире — отдельно продумать **сам процесс
|
||||||
«докачивания», «второго сезона», «версий/качества» и истории переходов.
|
распознавания и матчинга**: границы «разбор LLM / поиск в базе / сверка»,
|
||||||
|
что храним у кандидата, как считаем и показываем уверенность. Требует
|
||||||
|
проработки перед реализацией.
|
||||||
|
|
||||||
Связано: [database.md](specs/database.md) (PK `download`, `infohash`),
|
Связано: [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),
|
||||||
[architecture.md](specs/architecture.md) → «Идентификация торрента», пакет
|
[review-ux.md](specs/review-ux.md) (выбор источника в ревью, реализовано),
|
||||||
`store`.
|
[«Пересмотр набора capabilities»](#пересмотр-набора-capabilities-и-рефакторинг-спек).
|
||||||
|
|
||||||
### История переходов загрузки
|
### История переходов загрузки
|
||||||
|
|
||||||
@@ -193,10 +193,11 @@ qBittorrent, пул LLM-вызовов и запись в SQLite спроект
|
|||||||
Естественно ложится на собственный идентификатор загрузки.
|
Естественно ложится на собственный идентификатор загрузки.
|
||||||
|
|
||||||
Связано: детальный экран загрузки (`/download/{id}`) уже реализован — лог
|
Связано: детальный экран загрузки (`/download/{id}`) уже реализован — лог
|
||||||
переходов ложится в него; [workflow.md](specs/workflow.md) (граф состояний),
|
переходов ложится в него;
|
||||||
[«Наблюдаемость: метрики»](#наблюдаемость-метрики-и-учёт-стоимости-llm)
|
[drafts/logical-title-model.md](drafts/logical-title-model.md) §5.4 (схема
|
||||||
(длительности стадий), [database.md](specs/database.md), пакеты `worker`,
|
`state_transition`, actor `worker|human|reconcile`),
|
||||||
`store`.
|
[workflow.md](specs/workflow.md) (граф состояний),
|
||||||
|
[database.md](specs/database.md), пакеты `worker`, `store`.
|
||||||
|
|
||||||
### Машина состояний на go-библиотеке
|
### Машина состояний на go-библиотеке
|
||||||
|
|
||||||
@@ -222,23 +223,6 @@ qBittorrent, пул LLM-вызовов и запись в SQLite спроект
|
|||||||
веб = точные правки), [architecture.md](specs/architecture.md) →
|
веб = точные правки), [architecture.md](specs/architecture.md) →
|
||||||
«Транспорты».
|
«Транспорты».
|
||||||
|
|
||||||
### Раздачи с докачиванием (слияние при повторном добавлении)
|
|
||||||
|
|
||||||
Свежий сериал часто раздают по мере выхода: торрент содержит 5 эпизодов из
|
|
||||||
10. Позже его перезаливают целиком (или добавляют недостающие серии), и
|
|
||||||
пользователь повторно добавляет тот же торрент. Нужно распознать, что это
|
|
||||||
**та же** раздача/сезон, и повторить раскладку с **слиянием**: доложить
|
|
||||||
недостающие хардлинки, не дублируя уже разложенное и не перезаписывая
|
|
||||||
существующее (инвариант «существующее не трогаем»). Перекликается с
|
|
||||||
«Проблемой второго сезона», но здесь доливаются эпизоды внутри одного
|
|
||||||
сезона, а не новый сезон. Нужно продумать: как опознать повторное
|
|
||||||
добавление (хеш торрента / провайдер-id + сезон), как сверять состав файлов
|
|
||||||
и доливать только новые.
|
|
||||||
|
|
||||||
Связано: [«Проблема второго сезона»](#проблема-второго-сезона),
|
|
||||||
[jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка, идемпотентность),
|
|
||||||
[workflow.md](specs/workflow.md) (повторный прогон загрузки).
|
|
||||||
|
|
||||||
### Улучшения UI: показывать матч с записью метабазы
|
### Улучшения UI: показывать матч с записью метабазы
|
||||||
|
|
||||||
Web-сторона реализована: страница загрузки `/download/{id}` и экран ревью
|
Web-сторона реализована: страница загрузки `/download/{id}` и экран ревью
|
||||||
@@ -246,12 +230,32 @@ Web-сторона реализована: страница загрузки `/d
|
|||||||
загрузка — провайдер, id и ссылку на запись. Осталось довести то же в
|
загрузка — провайдер, id и ссылку на запись. Осталось довести то же в
|
||||||
**Telegram**: в уведомлениях/подтверждениях показывать запись матча (название,
|
**Telegram**: в уведомлениях/подтверждениях показывать запись матча (название,
|
||||||
год, провайдер-id, ссылку), чтобы ошибочную привязку было видно и из бота.
|
год, провайдер-id, ссылку), чтобы ошибочную привязку было видно и из бота.
|
||||||
Полный выбор источника в вебе — см. [«Ревью: выбор источника
|
Полный выбор источника в вебе уже реализован — см.
|
||||||
совпадения»](#ревью-выбор-источника-совпадения-и-предпросмотр).
|
[review-ux.md](specs/review-ux.md).
|
||||||
|
|
||||||
Связано: [review-ux.md](specs/review-ux.md), [recognition.md](specs/recognition.md)
|
Связано: [review-ux.md](specs/review-ux.md), [recognition.md](specs/recognition.md)
|
||||||
(матч в базе), [architecture.md](specs/architecture.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`) без сезонов, а
|
Релизы аниме часто нумеруют серии сквозным числом (`#137`) без сезонов, а
|
||||||
@@ -286,20 +290,6 @@ qBittorrent, без исходящих запросов на пользоват
|
|||||||
Связано: [architecture.md](specs/architecture.md) → «Деплой» (data-том,
|
Связано: [architecture.md](specs/architecture.md) → «Деплой» (data-том,
|
||||||
«бекапить-и-не-терять»), пакет `store`.
|
«бекапить-и-не-терять»), пакет `store`.
|
||||||
|
|
||||||
### Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)
|
|
||||||
|
|
||||||
Фильм уже разложен, позже добавили раздачу лучшего качества — сейчас это
|
|
||||||
просто новая задача, упирающаяся в «коллизию цели → review», без понятия
|
|
||||||
«это та же вещь, заменить версию». Нужно осознанно обработать апгрейд
|
|
||||||
качества: распознать тот же тайтл, предложить замену существующей раскладки
|
|
||||||
либо сосуществование версий (Jellyfin поддерживает несколько версий одного
|
|
||||||
фильма). Близко к «докачиванию», но про качество, а не про эпизоды.
|
|
||||||
|
|
||||||
Связано: [«Раздачи с докачиванием»](#раздачи-с-докачиванием-слияние-при-повторном-добавлении),
|
|
||||||
[jellyfin-layout.md](specs/jellyfin-layout.md) (never-overwrite, коллизия),
|
|
||||||
[architecture.md](specs/architecture.md) → «Идентификация торрента»
|
|
||||||
(репаки = разные infohash → разные задачи).
|
|
||||||
|
|
||||||
### Глубокий healthcheck и статус зависимостей
|
### Глубокий healthcheck и статус зависимостей
|
||||||
|
|
||||||
`/healthz` проверяет только сам сервис. Если qBittorrent, LLM или метабаза
|
`/healthz` проверяет только сам сервис. Если qBittorrent, LLM или метабаза
|
||||||
@@ -321,8 +311,72 @@ qBittorrent, без исходящих запросов на пользоват
|
|||||||
[«Многоступенчатая верификация»](#многоступенчатая-верификация-привязки-идея),
|
[«Многоступенчатая верификация»](#многоступенчатая-верификация-привязки-идея),
|
||||||
[architecture.md](specs/architecture.md) → «Хранилище» (`hint`, `override`).
|
[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
|
### Мгновенные обновления через SSE
|
||||||
|
|
||||||
Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто
|
Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто
|
||||||
@@ -336,6 +390,19 @@ qBittorrent, без исходящих запросов на пользоват
|
|||||||
Связано: [architecture.md](specs/architecture.md) → «Транспорты»,
|
Связано: [architecture.md](specs/architecture.md) → «Транспорты»,
|
||||||
[review-ux.md](specs/review-ux.md), пакет `httpapi`.
|
[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 списком (кнопки) для ручного выбора, а не молча
|
предлагать их в Telegram списком (кнопки) для ручного выбора, а не молча
|
||||||
брать первый/лучший. Веб остаётся точкой точных правок (полный выбор
|
брать первый/лучший. Веб остаётся точкой точных правок (полный выбор
|
||||||
источника — см. [«Ревью: выбор источника
|
источника уже реализован — см. [review-ux.md](specs/review-ux.md)), бот —
|
||||||
совпадения»](#ревью-выбор-источника-совпадения-и-предпросмотр)), бот —
|
|
||||||
быстрый выбор из готового короткого списка.
|
быстрый выбор из готового короткого списка.
|
||||||
|
|
||||||
Связано: [review-ux.md](specs/review-ux.md) (боты — быстрые действия, веб —
|
Связано: [review-ux.md](specs/review-ux.md) (боты — быстрые действия, веб —
|
||||||
|
|||||||
@@ -16,3 +16,6 @@
|
|||||||
(Ansible+Vault), валидация на старте.
|
(Ansible+Vault), валидация на старте.
|
||||||
- [errors.md](errors.md) — ошибки: stdlib, обёртка `%w`, `errors.Is`/`As`,
|
- [errors.md](errors.md) — ошибки: stdlib, обёртка `%w`, `errors.Is`/`As`,
|
||||||
трансляция на внешней границе.
|
трансляция на внешней границе.
|
||||||
|
- [database.md](database.md) — БД и идентификаторы: TEXT ULID PK через
|
||||||
|
`internal/ident` (без AUTOINCREMENT), lowercase + нормализация на границах,
|
||||||
|
естественные ключи у деталей.
|
||||||
|
|||||||
@@ -112,9 +112,17 @@ Ansible + Vault). Приложение просто читает TOML — отд
|
|||||||
- диапазоны: `recognition.auto_confidence_threshold` ∈ [0, 1],
|
- диапазоны: `recognition.auto_confidence_threshold` ∈ [0, 1],
|
||||||
`llm.max_retries` ≥ 0;
|
`llm.max_retries` ≥ 0;
|
||||||
- длительности парсятся (`llm.timeout`, `worker.poll_interval`, …);
|
- длительности парсятся (`llm.timeout`, `worker.poll_interval`, …);
|
||||||
|
- `general.timezone` — распознаваемая IANA-зона (валидируется
|
||||||
|
`time.LoadLocation`; zoneinfo встроен через `time/tzdata`, поэтому ошибка =
|
||||||
|
битое имя, а не отсутствие базы в окружении);
|
||||||
- включённые секции консистентны: `metadata.tmdb.enabled` → задан `api_key`;
|
- включённые секции консистентны: `metadata.tmdb.enabled` → задан `api_key`;
|
||||||
`jellyfin.enabled` → заданы `url`+`api_key`; `telegram.enabled` → `token`.
|
`jellyfin.enabled` → заданы `url`+`api_key`; `telegram.enabled` → `token`.
|
||||||
|
|
||||||
|
**Таймзоны.** Хранение времени в БД и логи — всегда UTC. Зона **отображения** в
|
||||||
|
веб-UI задаётся `[general].timezone` (дефолт `UTC`); только она конфигурируема,
|
||||||
|
на хранение/сортировку/логи не влияет. Бизнес-логика оперирует временем с явным
|
||||||
|
TZ (см. [CLAUDE.md](../../CLAUDE.md)).
|
||||||
|
|
||||||
## Структура в коде
|
## Структура в коде
|
||||||
|
|
||||||
- Весь разбор и валидация — в `internal/config`; наружу отдаётся готовая
|
- Весь разбор и валидация — в `internal/config`; наружу отдаётся готовая
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -18,7 +18,7 @@ OpenSpec-спеках (`### Requirement` с `SHALL`).
|
|||||||
фильтрацию и агрегацию через `jq`/DuckDB без регулярок.
|
фильтрацию и агрегацию через `jq`/DuckDB без регулярок.
|
||||||
|
|
||||||
```json
|
```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`
|
Если когда-нибудь поедем в несколько инстансов, добавим `service.version`
|
||||||
одной строкой при старте.
|
одной строкой при старте.
|
||||||
|
|
||||||
## Корреляция по download_id
|
## Корреляция по id сущности
|
||||||
|
|
||||||
Отдельный случайный `trace_id` не заводим — у загрузки уже есть стабильный
|
Отдельный случайный `trace_id` не заводим — у сущностей уже есть стабильные
|
||||||
осмысленный ключ: `download_id` (и `infohash`), он лежит в SQLite.
|
осмысленные ключи: ULID-идентификаторы (`download_id`, `recognition_id`,
|
||||||
|
`batch_id`, см. [database.md](database.md)), они лежат в SQLite.
|
||||||
|
|
||||||
- Заводим scoped-логгер на загрузку и протаскиваем его через
|
- Каждая запись, относящаяся к сущности, несёт её id в поле `<entity>_id`.
|
||||||
`context.Context` сквозь асинхронные стадии (приём → скачивание →
|
Для загрузки — scoped-логгер, протаскиваемый через `context.Context`
|
||||||
распознавание → раскладка), чтобы ключ дописывался на каждую запись сам:
|
сквозь асинхронные стадии (приём → скачивание → распознавание →
|
||||||
|
раскладка), чтобы ключ дописывался на каждую запись сам:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
log := log.With("download_id", id, "infohash", ih)
|
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`.
|
||||||
|
|
||||||
## Ошибки
|
## Ошибки
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -10,15 +10,23 @@
|
|||||||
> документации.
|
> документации.
|
||||||
>
|
>
|
||||||
> Состояние на: миграции `0001_init`, `0002_recognition_plan`,
|
> Состояние на: миграции `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) →
|
Назначение таблиц и почему так — [architecture.md](architecture.md) →
|
||||||
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.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-диаграмма
|
## ER-диаграмма
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
erDiagram
|
erDiagram
|
||||||
|
download ||--o{ download_infohash : "инфохэши (v1/v2)"
|
||||||
download ||--o{ recognition : "распознавания"
|
download ||--o{ recognition : "распознавания"
|
||||||
download ||--o{ hint : "подсказки"
|
download ||--o{ hint : "подсказки"
|
||||||
download ||--o{ override : "ручные правки"
|
download ||--o{ override : "ручные правки"
|
||||||
@@ -26,25 +34,30 @@ erDiagram
|
|||||||
recognition ||--o{ metadata_candidate : "кандидаты базы"
|
recognition ||--o{ metadata_candidate : "кандидаты базы"
|
||||||
|
|
||||||
download {
|
download {
|
||||||
INTEGER id PK "AUTOINCREMENT"
|
TEXT id PK "ULID (lowercase), генерится приложением"
|
||||||
TEXT source_type "NOT NULL; magnet|torrent|url"
|
TEXT source_type "NOT NULL; magnet|torrent|url"
|
||||||
TEXT source_ref "NOT NULL; magnet/url/путь"
|
TEXT source_ref "NOT NULL; magnet/url/путь"
|
||||||
TEXT display_name "NOT NULL DEFAULT ''; имя раздачи (rename qBittorrent), заголовок в UI (миграция 0005)"
|
TEXT display_name "NOT NULL DEFAULT ''; имя раздачи (rename qBittorrent), заголовок в UI (миграция 0005)"
|
||||||
TEXT context "NOT NULL DEFAULT ''"
|
TEXT context "NOT NULL DEFAULT ''"
|
||||||
TEXT infohash "nullable; может появиться позже приёма"
|
TEXT state "NOT NULL; см. workflow.md; активность выводится только из state"
|
||||||
TEXT idempotency_key "nullable; UNIQUE если NOT NULL"
|
|
||||||
TEXT state "NOT NULL; см. workflow.md"
|
|
||||||
TEXT error_code "nullable"
|
TEXT error_code "nullable"
|
||||||
TEXT error_msg "nullable"
|
TEXT error_msg "nullable"
|
||||||
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
|
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
|
||||||
TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)"
|
TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)"
|
||||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||||
TEXT updated_at "NOT NULL DEFAULT datetime('now')"
|
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 {
|
recognition {
|
||||||
INTEGER id PK "AUTOINCREMENT"
|
TEXT id PK "ULID"
|
||||||
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
|
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||||
INTEGER attempt_no "NOT NULL DEFAULT 1"
|
INTEGER attempt_no "NOT NULL DEFAULT 1"
|
||||||
INTEGER is_current "NOT NULL DEFAULT 1; 0/1"
|
INTEGER is_current "NOT NULL DEFAULT 1; 0/1"
|
||||||
TEXT media_type "nullable; movie|series"
|
TEXT media_type "nullable; movie|series"
|
||||||
@@ -57,54 +70,61 @@ erDiagram
|
|||||||
TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины не-авто"
|
TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины не-авто"
|
||||||
TEXT raw_llm "nullable; сырой ответ LLM"
|
TEXT raw_llm "nullable; сырой ответ LLM"
|
||||||
TEXT plan "nullable; JSON recognize.Plan (миграция 0002)"
|
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 {
|
hint {
|
||||||
INTEGER id PK "AUTOINCREMENT"
|
TEXT id PK "ULID"
|
||||||
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
|
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||||
TEXT text "NOT NULL"
|
TEXT text "NOT NULL"
|
||||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||||
}
|
}
|
||||||
|
|
||||||
override {
|
override {
|
||||||
INTEGER id PK "AUTOINCREMENT"
|
TEXT id PK "ULID"
|
||||||
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
|
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||||
TEXT field "NOT NULL; UNIQUE(download_id, field)"
|
TEXT field "NOT NULL; UNIQUE(download_id, field)"
|
||||||
TEXT value "NOT NULL"
|
TEXT value "NOT NULL"
|
||||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata_candidate {
|
metadata_candidate {
|
||||||
INTEGER id PK "AUTOINCREMENT"
|
TEXT id PK "ULID"
|
||||||
INTEGER recognition_id FK "NOT NULL; ON DELETE CASCADE"
|
TEXT recognition_id FK "NOT NULL; ON DELETE CASCADE"
|
||||||
TEXT provider "NOT NULL"
|
TEXT provider "NOT NULL"
|
||||||
TEXT provider_id "NOT NULL"
|
TEXT provider_id "NOT NULL"
|
||||||
TEXT title "nullable"
|
TEXT title "nullable"
|
||||||
INTEGER year "nullable"
|
INTEGER year "nullable"
|
||||||
TEXT url "nullable; ссылка на страницу на сайте провайдера"
|
TEXT url "nullable; ссылка на страницу на сайте провайдера"
|
||||||
INTEGER chosen "NOT NULL DEFAULT 0; 0/1"
|
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 {
|
file_link {
|
||||||
INTEGER id PK "AUTOINCREMENT"
|
TEXT id PK "ULID"
|
||||||
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
|
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||||
TEXT apply_batch_id "NOT NULL; батч для точечного undo"
|
TEXT apply_batch_id "NOT NULL; батч для точечного undo"
|
||||||
TEXT src_path "NOT NULL; исходный файл раздачи"
|
TEXT src_path "NOT NULL; исходный файл раздачи"
|
||||||
TEXT dst_path "NOT NULL; целевой хардлинк"
|
TEXT dst_path "NOT NULL; целевой хардлинк"
|
||||||
TEXT kind "NOT NULL; video|subtitle|..."
|
TEXT kind "NOT NULL; video|subtitle|..."
|
||||||
TEXT status "NOT NULL; linked|..."
|
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`;
|
- `download` 1 — N `download_infohash` / `recognition` / `hint` / `override`
|
||||||
`recognition` 1 — N `metadata_candidate`. Все дочерние — с
|
/ `file_link`; `recognition` 1 — N `metadata_candidate`. Все дочерние — с
|
||||||
`ON DELETE CASCADE`: удаление загрузки уносит её распознавания, подсказки,
|
`ON DELETE CASCADE`: удаление загрузки уносит её хеши, распознавания,
|
||||||
правки и ссылки.
|
подсказки, правки и ссылки.
|
||||||
|
- `download_infohash` — множество хешей одной загрузки (v1/v2 гибридного
|
||||||
|
торрента); один и тот же infohash может принадлежать нескольким загрузкам
|
||||||
|
во времени (повторный приём после терминального состояния). Инвариант «не
|
||||||
|
более одной активной загрузки на infohash» держат guarded-методы store
|
||||||
|
(`CreateDownloadIfNoActive`/`ActivateIfNoOtherActive`) в одной
|
||||||
|
write-транзакции — на уровне схемы он не выражается (условие на `state`).
|
||||||
- `download` ↔ `file_link` — один источник (раздача) ко многим разложенным
|
- `download` ↔ `file_link` — один источник (раздача) ко многим разложенным
|
||||||
файлам; внутри строки `file_link` связь `src_path → dst_path` — 1:1. Не
|
файлам; внутри строки `file_link` связь `src_path → dst_path` — 1:1. Не
|
||||||
каждый файл раздачи попадает в `file_link` (только распознанные медиа и
|
каждый файл раздачи попадает в `file_link` (только распознанные медиа и
|
||||||
@@ -112,8 +132,9 @@ erDiagram
|
|||||||
|
|
||||||
## Индексы и ограничения
|
## Индексы и ограничения
|
||||||
|
|
||||||
- `download`: `UNIQUE(idempotency_key) WHERE idempotency_key IS NOT NULL`;
|
- `download`: индекс по `state`.
|
||||||
индекс по `state`.
|
- `download_infohash`: PK `(infohash, download_id)` (он же индекс поиска по
|
||||||
|
хешу); индекс по `download_id`.
|
||||||
- `recognition`: индекс по `download_id`.
|
- `recognition`: индекс по `download_id`.
|
||||||
- `override`: `UNIQUE(download_id, field)`.
|
- `override`: `UNIQUE(download_id, field)`.
|
||||||
- `metadata_candidate`: индекс по `recognition_id`.
|
- `metadata_candidate`: индекс по `recognition_id`.
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Конвенции раскладки Jellyfin
|
# Конвенции раскладки Jellyfin
|
||||||
|
|
||||||
|
> **Источник истины переехал в OpenSpec** — `openspec/specs/file-layout/` (имена,
|
||||||
|
> хардлинки, коллизия, copy-fallback). Владение путём (`superseded`) и безопасный
|
||||||
|
> undo (`nlink<=1`) — в `openspec/specs/state-reconciliation/`. Этот файл —
|
||||||
|
> справочный нарратив; при расхождении верна спека OpenSpec.
|
||||||
|
|
||||||
Целевые имена и структура, в которые jellybit раскладывает файлы
|
Целевые имена и структура, в которые jellybit раскладывает файлы
|
||||||
хардлинками. Источники:
|
хардлинками. Источники:
|
||||||
[Movies](https://jellyfin.org/docs/general/server/media/movies),
|
[Movies](https://jellyfin.org/docs/general/server/media/movies),
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Распознавание контента
|
# Распознавание контента
|
||||||
|
|
||||||
|
> **Источник истины переехал в OpenSpec.** Актуальные требования —
|
||||||
|
> `openspec/specs/recognition/` (разбор сигналов LLM) и
|
||||||
|
> `openspec/specs/metadata-match/` (сверка с внешними базами). Этот файл остаётся
|
||||||
|
> справочным нарративом; при расхождении верна спека OpenSpec.
|
||||||
|
|
||||||
## Задача
|
## Задача
|
||||||
|
|
||||||
По доступным сигналам определить: фильм или сериал; каноническое название
|
По доступным сигналам определить: фильм или сериал; каноническое название
|
||||||
|
|||||||
+19
-4
@@ -1,5 +1,9 @@
|
|||||||
# Ревью раскладки человеком
|
# Ревью раскладки человеком
|
||||||
|
|
||||||
|
> **Источник истины переехал в OpenSpec** — `openspec/specs/review/`. Этот файл
|
||||||
|
> остаётся справочным нарративом (UI-макеты, разбор сценариев); при расхождении
|
||||||
|
> верна спека OpenSpec.
|
||||||
|
|
||||||
Что происходит, когда система не уверена в распознавании и не
|
Что происходит, когда система не уверена в распознавании и не
|
||||||
раскладывает файлы автоматически. Когда именно наступает ревью — см.
|
раскладывает файлы автоматически. Когда именно наступает ревью — см.
|
||||||
[recognition.md](recognition.md); место состояния `review` в общем потоке —
|
[recognition.md](recognition.md); место состояния `review` в общем потоке —
|
||||||
@@ -49,7 +53,14 @@ Fargo.S02.2015.WEB-DL.1080p.rus.eng 🟡 review
|
|||||||
Контекст: «второй сезон, рус+англ дорожки» [+ добавить → 🔁 перераспознать]
|
Контекст: «второй сезон, рус+англ дорожки» [+ добавить → 🔁 перераспознать]
|
||||||
|
|
||||||
Тип: ( ) фильм (•) сериал Название: Фарго Год: 2015 Сезон: 02
|
Тип: ( ) фильм (•) сериал Название: Фарго Год: 2015 Сезон: 02
|
||||||
База: [TMDB поиск…] [TVDB поиск…] выбрано: — (без базы) [ввести id]
|
|
||||||
|
Источник совпадения (единый список — выбираем источник, а не режим):
|
||||||
|
(•) распознано нейронкой (без базы) [активен]
|
||||||
|
( ) tvdb Fargo · 2014 id 269613 [запись↗] [предпросмотр▸] [выбрать]
|
||||||
|
( ) tmdb Fargo id 60622 [запись↗] [предпросмотр▸] [выбрать]
|
||||||
|
+ добавить вручную: [tmdb▾] [id или URL записи] [Добавить]
|
||||||
|
предпросмотр▸ раскрывает поля (тип/название/год, место под режиссёра) и
|
||||||
|
целевые пути ЭТОГО источника — до выбора, ничего не меняя
|
||||||
|
|
||||||
Файлы → серии:
|
Файлы → серии:
|
||||||
# | файл | размер | роль | S | E
|
# | файл | размер | роль | S | E
|
||||||
@@ -141,9 +152,13 @@ Telegram = одобрить / подсказать / выбрать кандид
|
|||||||
## Объём по версиям
|
## Объём по версиям
|
||||||
|
|
||||||
- **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать
|
- **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать
|
||||||
заново», переключатель типа, выбор кандидата базы / ручной ввод id /
|
заново», переключатель типа, **единый список источников совпадения**
|
||||||
«без базы», пометка файла «игнор», «Применить»/«Отклонить»/«Позже»,
|
(нейронка наравне с кандидатами баз; выбор/переключение/снятие в пользу
|
||||||
Undo и «Привязать заново». В Telegram — подтверждение с reply-подсказкой
|
нейронки), **ручное добавление источника по id или URL** (TMDB/IMDb — по
|
||||||
|
URL, TVDB — по числовому id), **предпросмотр полей и целевых путей каждого
|
||||||
|
источника до применения** (место под режиссёра зарезервировано), пометка
|
||||||
|
файла «игнор», «Применить»/«Отклонить»/«Позже», Undo и «Привязать заново».
|
||||||
|
В Telegram — подтверждение с reply-подсказкой
|
||||||
(«Уточнить»), переключатель типа, «Позже»/«Отклонить» и эскалация в веб;
|
(«Уточнить»), переключатель типа, «Позже»/«Отклонить» и эскалация в веб;
|
||||||
пинги о входе в review и готовности.
|
пинги о входе в review и готовности.
|
||||||
- **Ф5 (на будущее):** полный редактор маппинга «файл → серия»
|
- **Ф5 (на будущее):** полный редактор маппинга «файл → серия»
|
||||||
|
|||||||
@@ -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); детали распознавания —
|
устройство — в [architecture.md](architecture.md); детали распознавания —
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4
|
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/pelletier/go-toml/v2 v2.2.3
|
||||||
github.com/pressly/goose/v3 v3.22.1
|
github.com/pressly/goose/v3 v3.22.1
|
||||||
modernc.org/sqlite v1.34.1
|
modernc.org/sqlite v1.34.1
|
||||||
|
|||||||
@@ -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/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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
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=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const DefaultPath = "config.toml"
|
|||||||
|
|
||||||
// Config — корневая конфигурация сервиса (см. config.example.toml).
|
// Config — корневая конфигурация сервиса (см. config.example.toml).
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
General General `toml:"general"`
|
||||||
QBittorrent QBittorrent `toml:"qbittorrent"`
|
QBittorrent QBittorrent `toml:"qbittorrent"`
|
||||||
Paths Paths `toml:"paths"`
|
Paths Paths `toml:"paths"`
|
||||||
Storage Storage `toml:"storage"`
|
Storage Storage `toml:"storage"`
|
||||||
@@ -30,6 +31,15 @@ type Config struct {
|
|||||||
Log Log `toml:"log"`
|
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 и раскладка путей загрузок.
|
// QBittorrent — доступ к qBittorrent WebUI и раскладка путей загрузок.
|
||||||
type QBittorrent struct {
|
type QBittorrent struct {
|
||||||
URL string `toml:"url"`
|
URL string `toml:"url"`
|
||||||
@@ -152,10 +162,26 @@ func (d *Duration) UnmarshalText(text []byte) error {
|
|||||||
// Std возвращает обычный time.Duration.
|
// Std возвращает обычный time.Duration.
|
||||||
func (d Duration) Std() time.Duration { return time.Duration(d) }
|
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 возвращает конфиг с разумными умолчаниями; значения из файла
|
// Default возвращает конфиг с разумными умолчаниями; значения из файла
|
||||||
// перекрывают их при загрузке.
|
// перекрывают их при загрузке.
|
||||||
func Default() *Config {
|
func Default() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
|
General: General{Timezone: "UTC"},
|
||||||
QBittorrent: QBittorrent{
|
QBittorrent: QBittorrent{
|
||||||
URL: "http://qbit:8989",
|
URL: "http://qbit:8989",
|
||||||
Username: "admin",
|
Username: "admin",
|
||||||
@@ -229,6 +255,10 @@ func (c *Config) validate() error {
|
|||||||
if c.LLM.Type != "openai-compat" {
|
if c.LLM.Type != "openai-compat" {
|
||||||
errs = append(errs, fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type))
|
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, существующие каталоги.
|
// Медиа-пути песочницы: абсолютные, без traversal, существующие каталоги.
|
||||||
for _, p := range []struct{ name, path string }{
|
for _, p := range []struct{ name, path string }{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// validCfg возвращает минимально валидный конфиг поверх Default() с медиа-путями
|
// 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) {
|
func TestValidate_Errors(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
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 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"},
|
{"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"},
|
{"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 {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
@@ -12,18 +13,19 @@ import (
|
|||||||
// --- Страница просмотра одной загрузки ---
|
// --- Страница просмотра одной загрузки ---
|
||||||
|
|
||||||
type downloadDetailView struct {
|
type downloadDetailView struct {
|
||||||
ID int64
|
ID string
|
||||||
Title string
|
Title string
|
||||||
SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте»
|
SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте»
|
||||||
SourceFull string // полный источник (magnet) — блок «Информация о торренте»
|
SourceFull string // полный источник (magnet) — блок «Информация о торренте»
|
||||||
Infohash string
|
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
|
||||||
InfohashShort string
|
Context string
|
||||||
Context string
|
State string
|
||||||
State string
|
Error string
|
||||||
Error string
|
Note string
|
||||||
Note string
|
Added string // дата добавления (source_added_at → created_at), как в списке
|
||||||
CreatedAt string
|
AddedAgo string // относительная давность («5 дней назад»); пусто — если не распарсить
|
||||||
UpdatedAt string
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
|
||||||
// Распознавание (если есть план).
|
// Распознавание (если есть план).
|
||||||
HasPlan bool
|
HasPlan bool
|
||||||
@@ -31,6 +33,7 @@ type downloadDetailView struct {
|
|||||||
IsSeries bool
|
IsSeries bool
|
||||||
RecTitle string
|
RecTitle string
|
||||||
OriginalTitle string
|
OriginalTitle string
|
||||||
|
Season string // сводка сезонов для сериала (пусто для фильма)
|
||||||
Year int
|
Year int
|
||||||
Provider string
|
Provider string
|
||||||
ProviderID 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) {
|
func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := pathID(r)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "некорректный id", http.StatusBadRequest)
|
// Невалидный id = несуществующая сущность; в БД не ходим.
|
||||||
|
http.Error(w, "задача не найдена", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||||
@@ -81,25 +85,30 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
d := rd.Download
|
d := rd.Download
|
||||||
view := downloadDetailView{
|
view := downloadDetailView{
|
||||||
ID: id,
|
ID: id,
|
||||||
Title: detailTitle(d, rd),
|
Title: detailTitle(d, rd),
|
||||||
SourceType: string(d.SourceType),
|
SourceType: string(d.SourceType),
|
||||||
SourceFull: d.SourceRef,
|
SourceFull: d.SourceRef,
|
||||||
Infohash: d.Infohash.String,
|
Infohashes: d.HashList(),
|
||||||
InfohashShort: shortenHash(d.Infohash.String),
|
Context: d.Context,
|
||||||
Context: d.Context,
|
State: string(d.State),
|
||||||
State: string(d.State),
|
Error: d.ErrorMsg.String,
|
||||||
Error: d.ErrorMsg.String,
|
Note: desyncNote(d.State),
|
||||||
Note: desyncNote(d.State),
|
CreatedAt: d.CreatedAt,
|
||||||
CreatedAt: d.CreatedAt,
|
UpdatedAt: d.UpdatedAt,
|
||||||
UpdatedAt: d.UpdatedAt,
|
Terminal: d.State.IsTerminal(),
|
||||||
Terminal: d.State.IsTerminal(),
|
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
||||||
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
Undoable: d.State == store.StateDone,
|
||||||
Undoable: d.State == store.StateDone,
|
|
||||||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
||||||
d.State == store.StateTargetMissing,
|
d.State == store.StateTargetMissing,
|
||||||
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
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 {
|
if rd.Recognition != nil {
|
||||||
view.HasPlan = len(rd.Plan.Files) > 0
|
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.IsSeries = rd.Plan.Type == "series"
|
||||||
view.RecTitle = rd.Plan.Title
|
view.RecTitle = rd.Plan.Title
|
||||||
view.OriginalTitle = rd.Plan.OriginalTitle
|
view.OriginalTitle = rd.Plan.OriginalTitle
|
||||||
|
if view.IsSeries {
|
||||||
|
view.Season = seasonSummary(rd.Plan)
|
||||||
|
}
|
||||||
view.Year = rd.Plan.Year
|
view.Year = rd.Plan.Year
|
||||||
switch rd.Provider {
|
switch rd.Provider {
|
||||||
case "", "none":
|
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)
|
view.Seeding = buildSeeding(id, l, ok)
|
||||||
|
|
||||||
s.render(w, "download.html", view)
|
s.render(w, "download.html", view)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
)
|
)
|
||||||
@@ -36,6 +40,69 @@ func buildFileRows(plan recognize.Plan, preview []layout.Link) []fileRow {
|
|||||||
return rows
|
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] → «1–3», [1,3,4] → «1, 3–4».
|
||||||
|
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 — человекочитаемая роль файла раскладки.
|
// roleLabel — человекочитаемая роль файла раскладки.
|
||||||
func roleLabel(role string) string {
|
func roleLabel(role string) string {
|
||||||
switch role {
|
switch role {
|
||||||
|
|||||||
@@ -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))}, "Сезоны 1–3"},
|
||||||
|
{"разрыв", []recognize.PlanFile{ep(n(1)), ep(n(3)), ep(n(4))}, "Сезоны 1, 3–4"},
|
||||||
|
{"несортированный вход", []recognize.PlanFile{ep(n(3)), ep(n(1)), ep(n(2))}, "Сезоны 1–3"},
|
||||||
|
{"только спецвыпуски (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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("контекст всё ещё показан в карточке списка")
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
-52
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"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/ingest"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
@@ -35,15 +36,18 @@ type Ingestor interface {
|
|||||||
|
|
||||||
// Commander исполняет команды над задачей (worker.Worker).
|
// Commander исполняет команды над задачей (worker.Worker).
|
||||||
type Commander interface {
|
type Commander interface {
|
||||||
Cancel(ctx context.Context, id int64) error
|
Cancel(ctx context.Context, id string) error
|
||||||
Retry(ctx context.Context, id int64) error
|
Retry(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reader читает задачи (store.Store).
|
// Reader читает задачи (store.Store).
|
||||||
type Reader interface {
|
type Reader interface {
|
||||||
ListDownloads(ctx context.Context) ([]store.Download, error)
|
ListDownloads(ctx context.Context) ([]store.Download, error)
|
||||||
ListDownloadsPage(ctx context.Context, f store.ListFilter) ([]store.Download, int, 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 — зависимости транспорта.
|
// Deps — зависимости транспорта.
|
||||||
@@ -54,6 +58,8 @@ type Deps struct {
|
|||||||
Reader Reader
|
Reader Reader
|
||||||
Reviewer Reviewer
|
Reviewer Reviewer
|
||||||
Live LiveStatus
|
Live LiveStatus
|
||||||
|
// Loc — таймзона отображения дат в веб-UI (хранение всегда UTC). nil → UTC.
|
||||||
|
Loc *time.Location
|
||||||
}
|
}
|
||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
@@ -85,6 +91,9 @@ func NewRouter(d Deps) (http.Handler, error) {
|
|||||||
if d.Live == nil {
|
if d.Live == nil {
|
||||||
d.Live = noLive{} // источник телеметрии не подключён — деградируем штатно
|
d.Live = noLive{} // источник телеметрии не подключён — деградируем штатно
|
||||||
}
|
}
|
||||||
|
if d.Loc == nil {
|
||||||
|
d.Loc = time.UTC // таймзона отображения не задана — показываем в UTC
|
||||||
|
}
|
||||||
s := &server{deps: d, tmpl: tmpl, assetVer: assetVer}
|
s := &server{deps: d, tmpl: tmpl, assetVer: assetVer}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
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}/apply", s.handleApply)
|
||||||
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
|
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
|
||||||
r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize)
|
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}/ignore", s.handleIgnore)
|
||||||
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
|
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
|
||||||
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
|
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}/nobase", s.handleNoBase)
|
||||||
r.Post("/ui/downloads/{id}/defer", s.handleDefer)
|
r.Post("/ui/downloads/{id}/defer", s.handleDefer)
|
||||||
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
|
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
|
||||||
@@ -178,11 +187,8 @@ type pageLink struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type downloadView struct {
|
type downloadView struct {
|
||||||
ID int64
|
ID string
|
||||||
Title string // отображаемый заголовок карточки
|
Title string // отображаемый заголовок карточки
|
||||||
Infohash string // полный (для копирования)
|
|
||||||
InfohashShort string // усечённый (для показа)
|
|
||||||
Context string
|
|
||||||
State string
|
State string
|
||||||
Error string
|
Error string
|
||||||
Terminal bool
|
Terminal bool
|
||||||
@@ -193,6 +199,12 @@ type downloadView struct {
|
|||||||
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
||||||
Retriable bool // failed/stuck — можно повторить попытку
|
Retriable bool // failed/stuck — можно повторить попытку
|
||||||
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
||||||
|
|
||||||
|
// Обзор жизненного цикла (мета-строка карточки).
|
||||||
|
Added string // абсолютная дата добавления (TZ сервера), «2006-01-02»
|
||||||
|
AddedAgo string // относительная давность, «5 дней назад»
|
||||||
|
Size string // размер раздачи (снимок → фолбэк по файлам → «—»)
|
||||||
|
Ratio string // рейтинг отдачи (снимок → «—»)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listChips — определения чипов фильтра списка (порядок = порядок показа).
|
// 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 {
|
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 {
|
if v.IsDownloading {
|
||||||
l, ok := s.deps.Live.Live(d.Infohash.String)
|
|
||||||
v.Progress = buildProgress(d.ID, true, l, ok)
|
v.Progress = buildProgress(d.ID, true, l, ok)
|
||||||
}
|
}
|
||||||
view.Downloads = append(view.Downloads, v)
|
view.Downloads = append(view.Downloads, v)
|
||||||
@@ -394,15 +423,15 @@ func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
|
|||||||
// --- REST API ---
|
// --- REST API ---
|
||||||
|
|
||||||
type downloadDTO struct {
|
type downloadDTO struct {
|
||||||
ID int64 `json:"id"`
|
ID string `json:"id"` // ULID (lowercase)
|
||||||
SourceType string `json:"source_type"`
|
SourceType string `json:"source_type"`
|
||||||
Infohash string `json:"infohash,omitempty"`
|
Infohashes []string `json:"infohashes,omitempty"` // все хеши загрузки (v1 раньше v2)
|
||||||
Context string `json:"context,omitempty"`
|
Context string `json:"context,omitempty"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
ErrorCode string `json:"error_code,omitempty"`
|
ErrorCode string `json:"error_code,omitempty"`
|
||||||
ErrorMsg string `json:"error_msg,omitempty"`
|
ErrorMsg string `json:"error_msg,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type addRequest struct {
|
type addRequest struct {
|
||||||
@@ -411,16 +440,16 @@ type addRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type addResponse struct {
|
type addResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID string `json:"id"` // ULID (lowercase)
|
||||||
Infohash string `json:"infohash"`
|
Infohashes []string `json:"infohashes"` // хеши принятого источника (v1 раньше v2) — симметрично downloadDTO
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Deduplicated bool `json:"deduplicated"`
|
Deduplicated bool `json:"deduplicated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
|
||||||
downloads, err := s.deps.Reader.ListDownloads(r.Context())
|
downloads, err := s.deps.Reader.ListDownloads(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.apiErr(w, r, err, 0)
|
s.apiErr(w, r, err, "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]downloadDTO, 0, len(downloads))
|
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) {
|
func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := pathID(r)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
|
// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
|
||||||
|
writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
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) {
|
func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
var req addRequest
|
var req addRequest
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
|
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{
|
writeJSON(w, status, addResponse{
|
||||||
ID: res.DownloadID,
|
ID: res.DownloadID,
|
||||||
Infohash: res.Infohash,
|
Infohashes: res.Infohashes,
|
||||||
State: string(res.State),
|
State: string(res.State),
|
||||||
Deduplicated: res.Deduplicated,
|
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)
|
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)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
|
// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
|
||||||
|
writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := cmd(r.Context(), id); err != nil {
|
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)
|
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusOK, map[string]int64{"id": id})
|
writeJSON(w, http.StatusOK, map[string]string{"id": id})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, toDTO(*d))
|
writeJSON(w, http.StatusOK, toDTO(*d))
|
||||||
@@ -506,7 +537,7 @@ func toDTO(d store.Download) downloadDTO {
|
|||||||
return downloadDTO{
|
return downloadDTO{
|
||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
SourceType: string(d.SourceType),
|
SourceType: string(d.SourceType),
|
||||||
Infohash: d.Infohash.String,
|
Infohashes: d.HashList(),
|
||||||
Context: d.Context,
|
Context: d.Context,
|
||||||
State: string(d.State),
|
State: string(d.State),
|
||||||
ErrorCode: d.ErrorCode.String,
|
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)
|
state := string(d.State)
|
||||||
return downloadView{
|
v := downloadView{
|
||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
Title: downloadTitle(d),
|
Title: downloadTitle(d),
|
||||||
Infohash: d.Infohash.String,
|
|
||||||
InfohashShort: shortenHash(d.Infohash.String),
|
|
||||||
Context: d.Context,
|
|
||||||
State: state,
|
State: state,
|
||||||
Error: d.ErrorMsg.String,
|
Error: d.ErrorMsg.String,
|
||||||
Terminal: d.State.IsTerminal(),
|
Terminal: d.State.IsTerminal(),
|
||||||
@@ -535,6 +563,13 @@ func toView(d store.Download) downloadView {
|
|||||||
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
||||||
Note: desyncNote(d.State),
|
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,
|
// downloadTitle — заголовок загрузки для списка: имя раздачи (display_name,
|
||||||
@@ -557,12 +592,19 @@ func oneLine(s string) string {
|
|||||||
return strings.Join(strings.Fields(s), " ")
|
return strings.Join(strings.Fields(s), " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// shortenHash усекает infohash до вида "a1b2c3d4e5…f0" для компактного показа.
|
// addedTime — базис даты добавления карточки: время добавления в источник
|
||||||
func shortenHash(h string) string {
|
// (source_added_at, qBittorrent added_on) с фолбэком на время создания загрузки
|
||||||
if len(h) <= 12 {
|
// (created_at), согласованно с порядком списка. ok=false — распарсить нечего.
|
||||||
return h
|
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).
|
// desyncNote — пояснение состояния рассинхрона для UI (см. state-reconciliation).
|
||||||
@@ -586,12 +628,21 @@ func shorten(s string, n int) string {
|
|||||||
return s[:n] + "…"
|
return s[:n] + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
func pathID(r *http.Request) (int64, error) {
|
// pathID валидирует {id} из URL как ULID и нормализует к lowercase — до
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
// любого обращения к БД (сравнение в SQLite побайтовое). Невалидный id
|
||||||
if err != nil {
|
// трактуется вызывающими как несуществующая сущность (404).
|
||||||
return 0, errors.New("invalid id")
|
func pathID(r *http.Request) (string, error) {
|
||||||
|
return ident.Parse(chi.URLParam(r, "id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 id, nil
|
return worker.Live{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func redirectErr(w http.ResponseWriter, r *http.Request, msg string) {
|
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):
|
case errors.Is(err, worker.ErrConflict):
|
||||||
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
||||||
return http.StatusConflict, "действие недоступно в текущем состоянии"
|
return http.StatusConflict, "действие недоступно в текущем состоянии"
|
||||||
|
case errors.Is(err, errManualSource):
|
||||||
|
// Ошибка ручного ввода источника — показываем пользователю как есть.
|
||||||
|
return http.StatusBadRequest, errManualSource.Error()
|
||||||
|
case errors.Is(err, errInvalidCandidate):
|
||||||
|
return http.StatusBadRequest, errInvalidCandidate.Error()
|
||||||
default:
|
default:
|
||||||
return http.StatusInternalServerError, "внутренняя ошибка"
|
return http.StatusInternalServerError, "внутренняя ошибка"
|
||||||
}
|
}
|
||||||
@@ -627,9 +683,9 @@ func classifyErr(err error) (int, string) {
|
|||||||
// errBody — тело ошибки REST API: нейтральное сообщение + корреляционный ключ
|
// errBody — тело ошибки REST API: нейтральное сообщение + корреляционный ключ
|
||||||
// для владельца (download_id, если операция привязана к загрузке, иначе
|
// для владельца (download_id, если операция привязана к загрузке, иначе
|
||||||
// request_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}
|
body := map[string]any{"error": msg}
|
||||||
if downloadID > 0 {
|
if downloadID != "" {
|
||||||
body["download_id"] = downloadID
|
body["download_id"] = downloadID
|
||||||
} else {
|
} else {
|
||||||
body["request_id"] = middleware.GetReqID(r.Context())
|
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 по доменной ошибке (статус + тело).
|
// 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)
|
status, msg := classifyErr(err)
|
||||||
writeJSON(w, status, errBody(r, msg, downloadID))
|
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: нейтральный текст по
|
// userErr — сообщение публичного канала для веб-UI: нейтральный текст по
|
||||||
// доменной ошибке + корреляционный ключ владельцу (download_id, если операция
|
// доменной ошибке + корреляционный ключ владельцу (download_id, если операция
|
||||||
// привязана к загрузке, иначе request_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)
|
_, msg := classifyErr(err)
|
||||||
if downloadID > 0 {
|
if downloadID != "" {
|
||||||
return fmt.Sprintf("%s (download_id=%d)", msg, downloadID)
|
return fmt.Sprintf("%s (download_id=%s)", msg, downloadID)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
|
return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
|
||||||
}
|
}
|
||||||
|
|||||||
+250
-111
@@ -9,6 +9,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -21,6 +22,14 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Валидные lowercase-ULID для маршрутов (pathID валидирует формат).
|
||||||
|
const (
|
||||||
|
tid = "01arz3ndektsv4rrffq69g5fav"
|
||||||
|
tid2 = "01arz3ndektsv4rrffq69g5fb0"
|
||||||
|
cid = "01arz3ndektsv4rrffq69g5fc0"
|
||||||
|
cid2 = "01arz3ndektsv4rrffq69g5fd0"
|
||||||
|
)
|
||||||
|
|
||||||
type fakeIngestor struct {
|
type fakeIngestor struct {
|
||||||
res ingest.Result
|
res ingest.Result
|
||||||
err error
|
err error
|
||||||
@@ -33,12 +42,12 @@ func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Res
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeCommander struct {
|
type fakeCommander struct {
|
||||||
cancelled []int64
|
cancelled []string
|
||||||
retried []int64
|
retried []string
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeCommander) Cancel(_ context.Context, id int64) error {
|
func (f *fakeCommander) Cancel(_ context.Context, id string) error {
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return f.err
|
return f.err
|
||||||
}
|
}
|
||||||
@@ -46,7 +55,7 @@ func (f *fakeCommander) Cancel(_ context.Context, id int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeCommander) Retry(_ context.Context, id int64) error {
|
func (f *fakeCommander) Retry(_ context.Context, id string) error {
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return f.err
|
return f.err
|
||||||
}
|
}
|
||||||
@@ -76,12 +85,15 @@ func (f *fakeReader) ListDownloadsPage(_ context.Context, flt store.ListFilter)
|
|||||||
return f.list, total, nil
|
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 {
|
if f.get != nil {
|
||||||
return f.get, nil
|
return f.get, nil
|
||||||
}
|
}
|
||||||
return &store.Download{ID: id, State: store.StateCancelled}, 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 {
|
func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -98,7 +110,7 @@ func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIAdd(t *testing.T) {
|
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{}})
|
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||||
|
|
||||||
resp, err := http.Post(srv.URL+"/api/downloads", "application/json",
|
resp, err := http.Post(srv.URL+"/api/downloads", "application/json",
|
||||||
@@ -112,7 +124,7 @@ func TestAPIAdd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var got map[string]any
|
var got map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
_ = 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)
|
t.Errorf("body = %v", got)
|
||||||
}
|
}
|
||||||
if ing.lastReq.Context != "Дюна" {
|
if ing.lastReq.Context != "Дюна" {
|
||||||
@@ -138,8 +150,9 @@ func TestAPIAddBadInput(t *testing.T) {
|
|||||||
|
|
||||||
func TestAPIList(t *testing.T) {
|
func TestAPIList(t *testing.T) {
|
||||||
reader := &fakeReader{list: []store.Download{
|
reader := &fakeReader{list: []store.Download{
|
||||||
{ID: 2, SourceType: store.SourceMagnet, State: store.StateCompleted, Infohash: store.NullString("abc")},
|
{ID: tid2, SourceType: store.SourceMagnet, State: store.StateCompleted,
|
||||||
{ID: 1, SourceType: store.SourceMagnet, State: store.StateDownloading},
|
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})
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||||
|
|
||||||
@@ -153,7 +166,8 @@ func TestAPIList(t *testing.T) {
|
|||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Fatalf("len = %d, want 2", len(got))
|
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])
|
t.Errorf("first = %v", got[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +176,7 @@ func TestAPICancel(t *testing.T) {
|
|||||||
cmd := &fakeCommander{}
|
cmd := &fakeCommander{}
|
||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -170,7 +184,7 @@ func TestAPICancel(t *testing.T) {
|
|||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
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)
|
t.Errorf("cancel вызван неверно: %v", cmd.cancelled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +193,7 @@ func TestUIRetry(t *testing.T) {
|
|||||||
cmd := &fakeCommander{}
|
cmd := &fakeCommander{}
|
||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -187,7 +201,7 @@ func TestUIRetry(t *testing.T) {
|
|||||||
if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200
|
if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200
|
||||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
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)
|
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)}
|
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{}})
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -207,14 +221,14 @@ func TestAPICommandConflict(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var got map[string]any
|
var got map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
if got["download_id"].(float64) != 5 {
|
if got["download_id"] != tid {
|
||||||
t.Errorf("download_id корреляции нет: %v", got)
|
t.Errorf("download_id корреляции нет: %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIndexRenders(t *testing.T) {
|
func TestIndexRenders(t *testing.T) {
|
||||||
reader := &fakeReader{list: []store.Download{
|
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})
|
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) {
|
func TestIndexTitleFromDisplayName(t *testing.T) {
|
||||||
// Заголовок карточки — имя раздачи (display_name), а не сырой magnet.
|
// Заголовок карточки — имя раздачи (display_name), а не сырой magnet.
|
||||||
reader := &fakeReader{list: []store.Download{{
|
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,
|
DisplayName: "Дюна: Часть вторая (2024)", State: store.StateDownloading,
|
||||||
}}}
|
}}}
|
||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
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 {
|
func detailReviewData(provider, providerID, chosenID, chosenURL string) *worker.ReviewData {
|
||||||
rd := &worker.ReviewData{
|
rd := &worker.ReviewData{
|
||||||
Download: store.Download{
|
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: "Дюна",
|
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{
|
Plan: recognize.Plan{
|
||||||
Type: recognize.MediaMovie, Title: "Дюна", Year: 2024,
|
Type: recognize.MediaMovie, Title: "Дюна", Year: 2024,
|
||||||
Files: []recognize.PlanFile{{Src: "dune.mkv", Role: recognize.RoleMain}},
|
Files: []recognize.PlanFile{{Src: "dune.mkv", Role: recognize.RoleMain}},
|
||||||
@@ -316,7 +330,7 @@ func detailReviewData(provider, providerID, chosenID, chosenURL string) *worker.
|
|||||||
}
|
}
|
||||||
if chosenID != "" {
|
if chosenID != "" {
|
||||||
rd.Candidates = []store.MetadataCandidate{{
|
rd.Candidates = []store.MetadataCandidate{{
|
||||||
ID: 10, Provider: "tmdb", ProviderID: chosenID, Chosen: true,
|
ID: cid, Provider: "tmdb", ProviderID: chosenID, Chosen: true,
|
||||||
URL: store.NullString(chosenURL),
|
URL: store.NullString(chosenURL),
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
@@ -329,7 +343,7 @@ func TestDownloadMatchLinkFromCandidate(t *testing.T) {
|
|||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
Reader: &fakeReader{}, Reviewer: rv})
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
|
|
||||||
resp, err := http.Get(srv.URL + "/download/1")
|
resp, err := http.Get(srv.URL + "/download/" + tid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -347,7 +361,7 @@ func TestDownloadMatchLinkMismatchUsesBuilt(t *testing.T) {
|
|||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
Reader: &fakeReader{}, Reviewer: rv})
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
|
|
||||||
resp, err := http.Get(srv.URL + "/download/1")
|
resp, err := http.Get(srv.URL + "/download/" + tid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -366,7 +380,7 @@ func TestDownloadTorrentInfoBlock(t *testing.T) {
|
|||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
Reader: &fakeReader{}, Reviewer: rv})
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
|
|
||||||
resp, err := http.Get(srv.URL + "/download/1")
|
resp, err := http.Get(srv.URL + "/download/" + tid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -384,105 +398,117 @@ func TestDownloadTorrentInfoBlock(t *testing.T) {
|
|||||||
type fakeReviewer struct {
|
type fakeReviewer struct {
|
||||||
data *worker.ReviewData
|
data *worker.ReviewData
|
||||||
applyErr error
|
applyErr error
|
||||||
refined map[int64]string
|
refined map[string]string
|
||||||
typed map[int64]string
|
ignored map[string]string
|
||||||
ignored map[int64]string
|
chosen map[string]string
|
||||||
chosen map[int64]int64
|
providerSet map[string]string
|
||||||
providerSet map[int64]string
|
sourceAdded map[string]string
|
||||||
applied []int64
|
applied []string
|
||||||
deferred []int64
|
deferred []string
|
||||||
undone []int64
|
undone []string
|
||||||
relinked []int64
|
relinked []string
|
||||||
rerecognized []int64
|
rerecognized []string
|
||||||
cleared []int64
|
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
|
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 {
|
if f.applyErr != nil {
|
||||||
return f.applyErr
|
return f.applyErr
|
||||||
}
|
}
|
||||||
f.applied = append(f.applied, id)
|
f.applied = append(f.applied, id)
|
||||||
return nil
|
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 {
|
if f.refined == nil {
|
||||||
f.refined = map[int64]string{}
|
f.refined = map[string]string{}
|
||||||
}
|
}
|
||||||
f.refined[id] = hint
|
f.refined[id] = hint
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (f *fakeReviewer) SetType(_ context.Context, id int64, t string) error {
|
func (f *fakeReviewer) IgnoreFile(_ context.Context, id string, src 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 {
|
|
||||||
if f.ignored == nil {
|
if f.ignored == nil {
|
||||||
f.ignored = map[int64]string{}
|
f.ignored = map[string]string{}
|
||||||
}
|
}
|
||||||
f.ignored[id] = src
|
f.ignored[id] = src
|
||||||
return nil
|
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)
|
f.deferred = append(f.deferred, id)
|
||||||
return nil
|
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)
|
f.undone = append(f.undone, id)
|
||||||
return nil
|
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)
|
f.relinked = append(f.relinked, id)
|
||||||
return nil
|
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)
|
f.rerecognized = append(f.rerecognized, id)
|
||||||
return nil
|
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 {
|
if f.chosen == nil {
|
||||||
f.chosen = map[int64]int64{}
|
f.chosen = map[string]string{}
|
||||||
}
|
}
|
||||||
f.chosen[id] = candidateID
|
f.chosen[id] = candidateID
|
||||||
return nil
|
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 {
|
if f.providerSet == nil {
|
||||||
f.providerSet = map[int64]string{}
|
f.providerSet = map[string]string{}
|
||||||
}
|
}
|
||||||
f.providerSet[id] = provider + ":" + providerID
|
f.providerSet[id] = provider + ":" + providerID
|
||||||
return nil
|
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)
|
f.cleared = append(f.cleared, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func seriesReviewData() *worker.ReviewData {
|
func seriesReviewData() *worker.ReviewData {
|
||||||
s, e := 2, 1
|
s, e := 2, 1
|
||||||
|
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{
|
||||||
|
{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
|
||||||
|
}
|
||||||
return &worker.ReviewData{
|
return &worker.ReviewData{
|
||||||
Download: store.Download{ID: 1, State: store.StateReview, SourceRef: "magnet:?xt=urn:btih:abc"},
|
Download: store.Download{ID: tid, State: store.StateReview, SourceRef: "magnet:?xt=urn:btih:abc"},
|
||||||
Recognition: &store.Recognition{
|
Recognition: &store.Recognition{
|
||||||
ID: 1, DownloadID: 1, IsCurrent: true, Reasons: `["нет матча в базе"]`,
|
ID: "1", DownloadID: tid, IsCurrent: true, Reasons: `["нет матча в базе"]`,
|
||||||
},
|
|
||||||
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{
|
|
||||||
{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
|
|
||||||
},
|
},
|
||||||
|
Plan: plan,
|
||||||
|
Preview: preview,
|
||||||
Candidates: []store.MetadataCandidate{
|
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}},
|
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{"второй сезон"},
|
Hints: []string{"второй сезон"},
|
||||||
}
|
}
|
||||||
@@ -500,7 +526,7 @@ func TestReviewRenders(t *testing.T) {
|
|||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
Reader: &fakeReader{}, Reviewer: rv})
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
|
|
||||||
resp, err := http.Get(srv.URL + "/review/1")
|
resp, err := http.Get(srv.URL + "/review/" + tid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -511,25 +537,31 @@ func TestReviewRenders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
|
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
|
||||||
"Season 02", "Применить", "Уточнить",
|
"Season 02", "Применить", "Уточнить",
|
||||||
"Источник совпадения", "269613", "выбрать", "Без базы"} {
|
"Источник и раскладка", "269613", "распознано нейронкой", "Добавить",
|
||||||
|
"Сезон 2"} {
|
||||||
if !strings.Contains(string(body), want) {
|
if !strings.Contains(string(body), want) {
|
||||||
t.Errorf("страница ревью не содержит %q", want)
|
t.Errorf("страница ревью не содержит %q", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Кнопки «выбрать» больше нет — выбор одним кликом по радио.
|
||||||
|
if strings.Contains(string(body), ">выбрать<") {
|
||||||
|
t.Error("страница ревью всё ещё содержит кнопку «выбрать»")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReviewShowsMatchLink(t *testing.T) {
|
func TestReviewShowsMatchLink(t *testing.T) {
|
||||||
// Подтверждённый матч в блоке «Источник совпадения» — ссылкой на запись.
|
// Подтверждённый матч в списке источников — ссылкой на запись.
|
||||||
rd := seriesReviewData()
|
rd := seriesReviewData()
|
||||||
rd.Provider = "tvdb"
|
rd.Provider = "tvdb"
|
||||||
rd.ProviderID = "269613"
|
rd.ProviderID = "269613"
|
||||||
rd.Candidates[0].Chosen = true // tvdb 269613 в seriesReviewData
|
// Активен кандидат tvdb (индекс 1: нейронка, tvdb, tmdb).
|
||||||
rd.Candidates[0].URL = store.NullString("https://www.thetvdb.com/dereferrer/series/269613")
|
rd.Sources[0].Active = false
|
||||||
|
rd.Sources[1].Active = true
|
||||||
rv := &fakeReviewer{data: rd}
|
rv := &fakeReviewer{data: rd}
|
||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
Reader: &fakeReader{}, Reviewer: rv})
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
|
|
||||||
resp, err := http.Get(srv.URL + "/review/1")
|
resp, err := http.Get(srv.URL + "/review/" + tid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -545,16 +577,16 @@ func TestChooseCandidate(t *testing.T) {
|
|||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
Reader: &fakeReader{}, Reviewer: rv})
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
|
|
||||||
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/1/candidate",
|
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/candidate",
|
||||||
map[string][]string{"candidate_id": {"10"}})
|
map[string][]string{"candidate_id": {cid}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if rv.chosen[1] != 10 {
|
if rv.chosen[tid] != cid {
|
||||||
t.Errorf("ChooseCandidate получил %d", rv.chosen[1])
|
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)
|
t.Errorf("Location = %q", loc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,28 +597,108 @@ func TestSetProviderAndNoBase(t *testing.T) {
|
|||||||
Reader: &fakeReader{}, Reviewer: rv})
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
cl := noRedirectClient()
|
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 {
|
map[string][]string{"provider": {"tvdb"}, "provider_id": {"269613"}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if rv.providerSet[1] != "tvdb:269613" {
|
if rv.providerSet[tid] != "tvdb:269613" {
|
||||||
t.Errorf("SetProviderID получил %q", rv.providerSet[1])
|
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)
|
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)
|
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) {
|
func TestApplyRedirectsToIndex(t *testing.T) {
|
||||||
rv := &fakeReviewer{data: seriesReviewData()}
|
rv := &fakeReviewer{data: seriesReviewData()}
|
||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
Reader: &fakeReader{}, Reviewer: rv})
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -607,13 +719,13 @@ func TestApplyCollisionRedirectsToReview(t *testing.T) {
|
|||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
Reader: &fakeReader{}, Reviewer: rv})
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
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, want /review/1?err=...", loc)
|
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{},
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
Reader: &fakeReader{}, Reviewer: rv})
|
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": {"это второй сезон"}})
|
map[string][]string{"hint": {"это второй сезон"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if rv.refined[1] != "это второй сезон" {
|
if rv.refined[tid] != "это второй сезон" {
|
||||||
t.Errorf("Refine получил %q", rv.refined[1])
|
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)
|
t.Errorf("Location = %q", loc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIgnoreAndType(t *testing.T) {
|
func TestIgnoreFile(t *testing.T) {
|
||||||
rv := &fakeReviewer{data: seriesReviewData()}
|
rv := &fakeReviewer{data: seriesReviewData()}
|
||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
Reader: &fakeReader{}, Reviewer: rv})
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
cl := noRedirectClient()
|
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 {
|
map[string][]string{"src": {"Fargo/sample.mkv"}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if rv.ignored[1] != "Fargo/sample.mkv" {
|
if rv.ignored[tid] != "Fargo/sample.mkv" {
|
||||||
t.Errorf("IgnoreFile получил %q", rv.ignored[1])
|
t.Errorf("IgnoreFile получил %q", rv.ignored[tid])
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/type",
|
// TestChooseCandidateHTMX: на htmx-запрос выбор возвращает партиал блока
|
||||||
map[string][]string{"type": {"movie"}}); err != nil {
|
// источника (а не полную страницу и не редирект), обновлённый под выбор.
|
||||||
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if rv.typed[1] != "movie" {
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
t.Errorf("SetType получил %q", rv.typed[1])
|
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})
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
cl := noRedirectClient()
|
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)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(rv.undone) != 1 || len(rv.deferred) != 1 {
|
if len(rv.undone) != 1 || len(rv.deferred) != 1 {
|
||||||
@@ -682,11 +821,11 @@ func TestRelink(t *testing.T) {
|
|||||||
Reader: &fakeReader{}, Reviewer: rv})
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
cl := noRedirectClient()
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(rv.relinked) != 1 || rv.relinked[0] != 1 {
|
if len(rv.relinked) != 1 || rv.relinked[0] != tid {
|
||||||
t.Errorf("relinked = %v, want [1]", rv.relinked)
|
t.Errorf("relinked = %v, want [%s]", rv.relinked, tid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -696,10 +835,10 @@ func TestRerecognize(t *testing.T) {
|
|||||||
Reader: &fakeReader{}, Reviewer: rv})
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
cl := noRedirectClient()
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(rv.rerecognized) != 1 || rv.rerecognized[0] != 1 {
|
if len(rv.rerecognized) != 1 || rv.rerecognized[0] != tid {
|
||||||
t.Errorf("rerecognized = %v, want [1]", rv.rerecognized)
|
t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"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:
|
// /progress). Active управляется store-состоянием (downloading), а не qbt:
|
||||||
// когда задача покидает downloading, фрагмент возвращается без поллинга.
|
// когда задача покидает downloading, фрагмент возвращается без поллинга.
|
||||||
type progressView struct {
|
type progressView struct {
|
||||||
ID int64
|
ID string
|
||||||
Active bool // store-состояние downloading → показываем бар и поллим
|
Active bool // store-состояние downloading → показываем бар и поллим
|
||||||
Has bool // есть данные снимка
|
Has bool // есть данные снимка
|
||||||
Percent int
|
Percent int
|
||||||
@@ -39,7 +40,7 @@ type progressView struct {
|
|||||||
// Has истинно только если торрент сидирует и данные есть — иначе секция
|
// Has истинно только если торрент сидирует и данные есть — иначе секция
|
||||||
// деградирует (пустой контейнер, поллинг прекращается).
|
// деградирует (пустой контейнер, поллинг прекращается).
|
||||||
type seedingView struct {
|
type seedingView struct {
|
||||||
ID int64
|
ID string
|
||||||
Has bool
|
Has bool
|
||||||
Percent int
|
Percent int
|
||||||
Ratio string
|
Ratio string
|
||||||
@@ -49,7 +50,7 @@ type seedingView struct {
|
|||||||
UpSpeed string
|
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}
|
v := progressView{ID: id, Active: active}
|
||||||
if ok {
|
if ok {
|
||||||
v.Has = true
|
v.Has = true
|
||||||
@@ -64,7 +65,7 @@ func buildProgress(id int64, active bool, l worker.Live, ok bool) progressView {
|
|||||||
return v
|
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}
|
v := seedingView{ID: id}
|
||||||
if ok && l.Seeding {
|
if ok && l.Seeding {
|
||||||
v.Has = true
|
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) {
|
func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := pathID(r)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "некорректный id", http.StatusBadRequest)
|
http.Error(w, "не найдено", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||||
@@ -91,7 +92,7 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
active := d.State == store.StateDownloading
|
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))
|
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) {
|
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := pathID(r)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "некорректный id", http.StatusBadRequest)
|
http.Error(w, "не найдено", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
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)
|
s.fragErr(w, err, id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
l, ok := s.deps.Live.Live(d.Infohash.String)
|
l, ok := s.liveFor(*d)
|
||||||
s.render(w, "seeding", buildSeeding(id, l, ok))
|
s.render(w, "seeding", buildSeeding(id, l, ok))
|
||||||
}
|
}
|
||||||
|
|
||||||
// fragErr транслирует ошибку чтения задачи для фрагмент-роутов: ErrNotFound →
|
// fragErr транслирует ошибку чтения задачи для фрагмент-роутов: ErrNotFound →
|
||||||
// 404, прочее → 500 (полная ошибка уже залогирована на доменной границе).
|
// 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) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
http.Error(w, "не найдено", http.StatusNotFound)
|
http.Error(w, "не найдено", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
@@ -184,3 +185,76 @@ func fmtRatio(r float64) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%.2f", r)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,16 +12,16 @@ import (
|
|||||||
// TestFragProgressDownloading: активная задача → фрагмент с прогрессом,
|
// TestFragProgressDownloading: активная задача → фрагмент с прогрессом,
|
||||||
// значениями снимка и атрибутами htmx-поллинга.
|
// значениями снимка и атрибутами htmx-поллинга.
|
||||||
func TestFragProgressDownloading(t *testing.T) {
|
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}}}
|
lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
|
||||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv)
|
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 {
|
if rr.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", rr.Code)
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
}
|
}
|
||||||
body := rr.Body.String()
|
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) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body)
|
t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body)
|
||||||
}
|
}
|
||||||
@@ -31,10 +31,10 @@ func TestFragProgressDownloading(t *testing.T) {
|
|||||||
// TestFragProgressStopsWhenNotDownloading: когда задача покинула downloading,
|
// TestFragProgressStopsWhenNotDownloading: когда задача покинула downloading,
|
||||||
// фрагмент отдаётся без атрибутов поллинга (поллинг прекращается).
|
// фрагмент отдаётся без атрибутов поллинга (поллинг прекращается).
|
||||||
func TestFragProgressStopsWhenNotDownloading(t *testing.T) {
|
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{})
|
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 {
|
if rr.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", rr.Code)
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
}
|
}
|
||||||
@@ -46,13 +46,13 @@ func TestFragProgressStopsWhenNotDownloading(t *testing.T) {
|
|||||||
// TestFragSeeding: сидирующая задача → секция «Раздача» со статистикой и
|
// TestFragSeeding: сидирующая задача → секция «Раздача» со статистикой и
|
||||||
// поллингом.
|
// поллингом.
|
||||||
func TestFragSeeding(t *testing.T) {
|
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": {
|
lv := stubLive{m: map[string]worker.Live{"ih9": {
|
||||||
Seeding: true, Progress: 1, Ratio: 2.41, Seeds: 38, Peers: 14, Uploaded: 1 << 30, UpSpeed: 1153433,
|
Seeding: true, Progress: 1, Ratio: 2.41, Seeds: 38, Peers: 14, Uploaded: 1 << 30, UpSpeed: 1153433,
|
||||||
}}}
|
}}}
|
||||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv)
|
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 {
|
if rr.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", rr.Code)
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
}
|
}
|
||||||
@@ -66,10 +66,10 @@ func TestFragSeeding(t *testing.T) {
|
|||||||
|
|
||||||
// TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет.
|
// TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет.
|
||||||
func TestFragSeedingDegrades(t *testing.T) {
|
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{})
|
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 {
|
if rr.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", rr.Code)
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@ func TestFragSeedingDegrades(t *testing.T) {
|
|||||||
// TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже
|
// TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже
|
||||||
// в первом кадре (значения снимка) и атрибуты поллинга.
|
// в первом кадре (значения снимка) и атрибуты поллинга.
|
||||||
func TestIndexCardShowsLiveProgress(t *testing.T) {
|
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}}}
|
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)
|
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)
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
}
|
}
|
||||||
body := rr.Body.String()
|
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) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("карточка без живого прогресса: нет %q", want)
|
t.Errorf("карточка без живого прогресса: нет %q", want)
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,11 @@ func TestIndexCardShowsLiveProgress(t *testing.T) {
|
|||||||
// TestFragNotFound: фрагмент несуществующей задачи → 404.
|
// TestFragNotFound: фрагмент несуществующей задачи → 404.
|
||||||
func TestFragNotFound(t *testing.T) {
|
func TestFragNotFound(t *testing.T) {
|
||||||
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
|
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)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 не
|
// TestMatchURLNoLinkWhenUnbuildable — эффективный провайдер, для которого URL не
|
||||||
// строится и совпадающего кандидата нет, даёт пустую ссылку (транспорт покажет
|
// строится и совпадающего кандидата нет, даёт пустую ссылку (транспорт покажет
|
||||||
// матч текстом — сценарий «URL записи неизвестен»).
|
// матч текстом — сценарий «URL записи неизвестен»).
|
||||||
|
|||||||
@@ -15,41 +15,48 @@ import (
|
|||||||
|
|
||||||
// stubReader — минимальный Reader для проверки рендера списка.
|
// stubReader — минимальный Reader для проверки рендера списка.
|
||||||
type stubReader struct {
|
type stubReader struct {
|
||||||
list []store.Download
|
list []store.Download
|
||||||
one *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) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
|
||||||
func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) {
|
func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) {
|
||||||
return s.list, len(s.list), nil
|
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 {
|
if s.one == nil {
|
||||||
return nil, store.ErrNotFound
|
return nil, store.ErrNotFound
|
||||||
}
|
}
|
||||||
return s.one, nil
|
return s.one, nil
|
||||||
}
|
}
|
||||||
|
func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) {
|
||||||
|
return s.sizes, nil
|
||||||
|
}
|
||||||
|
|
||||||
// stubReviewer — Reviewer-заглушка (нужна для /download/{id}).
|
// stubReviewer — Reviewer-заглушка (нужна для /download/{id}).
|
||||||
type stubReviewer struct{ data *worker.ReviewData }
|
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 {
|
if s.data == nil {
|
||||||
return nil, store.ErrNotFound
|
return nil, store.ErrNotFound
|
||||||
}
|
}
|
||||||
return s.data, nil
|
return s.data, nil
|
||||||
}
|
}
|
||||||
func (stubReviewer) Apply(context.Context, int64) error { return nil }
|
func (stubReviewer) Apply(context.Context, string) error { return nil }
|
||||||
func (stubReviewer) Refine(context.Context, int64, string) error { return nil }
|
func (stubReviewer) Refine(context.Context, string, string) error { return nil }
|
||||||
func (stubReviewer) SetType(context.Context, int64, string) error { return nil }
|
func (stubReviewer) SetType(context.Context, string, string) error { return nil }
|
||||||
func (stubReviewer) IgnoreFile(context.Context, int64, string) error { return nil }
|
func (stubReviewer) IgnoreFile(context.Context, string, string) error { return nil }
|
||||||
func (stubReviewer) Defer(context.Context, int64) error { return nil }
|
func (stubReviewer) Defer(context.Context, string) error { return nil }
|
||||||
func (stubReviewer) Undo(context.Context, int64) error { return nil }
|
func (stubReviewer) Undo(context.Context, string) error { return nil }
|
||||||
func (stubReviewer) Relink(context.Context, int64) error { return nil }
|
func (stubReviewer) Relink(context.Context, string) error { return nil }
|
||||||
func (stubReviewer) Rerecognize(context.Context, int64) error { return nil }
|
func (stubReviewer) Rerecognize(context.Context, string) error { return nil }
|
||||||
func (stubReviewer) ChooseCandidate(context.Context, int64, int64) error { return nil }
|
func (stubReviewer) ChooseCandidate(context.Context, string, string) error { return nil }
|
||||||
func (stubReviewer) SetProviderID(context.Context, int64, string, string) error { return nil }
|
func (stubReviewer) SetProviderID(context.Context, string, string, string) error { return nil }
|
||||||
func (stubReviewer) ClearProvider(context.Context, int64) error { return nil }
|
func (stubReviewer) AddManualSource(context.Context, string, string, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (stubReviewer) ClearProvider(context.Context, string) error { return nil }
|
||||||
|
|
||||||
// stubLive — заглушка источника живой телеметрии.
|
// stubLive — заглушка источника живой телеметрии.
|
||||||
type stubLive struct{ m map[string]worker.Live }
|
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
|
return rr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testULID — валидный lowercase-ULID для маршрутов (pathID валидирует формат).
|
||||||
|
const testULID = "01arz3ndektsv4rrffq69g5fav"
|
||||||
|
|
||||||
// TestRouterRendersPages проверяет, что шаблоны парсятся и страницы рендерятся.
|
// TestRouterRendersPages проверяет, что шаблоны парсятся и страницы рендерятся.
|
||||||
func TestRouterRendersPages(t *testing.T) {
|
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,
|
h := testRouter(t,
|
||||||
stubReader{list: []store.Download{dl}, one: &dl},
|
stubReader{list: []store.Download{dl}, one: &dl},
|
||||||
stubReviewer{data: &worker.ReviewData{Download: dl}},
|
stubReviewer{data: &worker.ReviewData{Download: dl}},
|
||||||
@@ -99,8 +111,8 @@ func TestRouterRendersPages(t *testing.T) {
|
|||||||
t.Errorf("index не содержит бейдж статуса")
|
t.Errorf("index не содержит бейдж статуса")
|
||||||
}
|
}
|
||||||
|
|
||||||
if rr := get(t, h, "/download/7"); rr.Code != http.StatusOK {
|
if rr := get(t, h, "/download/"+testULID); rr.Code != http.StatusOK {
|
||||||
t.Fatalf("GET /download/7 = %d, want 200", rr.Code)
|
t.Fatalf("GET /download/{id} = %d, want 200", rr.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +134,27 @@ func TestStaticServed(t *testing.T) {
|
|||||||
// TestDownloadNotFound — несуществующая загрузка → 404.
|
// TestDownloadNotFound — несуществующая загрузка → 404.
|
||||||
func TestDownloadNotFound(t *testing.T) {
|
func TestDownloadNotFound(t *testing.T) {
|
||||||
h := testRouter(t, stubReader{}, stubReviewer{})
|
h := testRouter(t, stubReader{}, stubReviewer{})
|
||||||
if rr := get(t, h, "/download/999"); rr.Code != http.StatusNotFound {
|
if rr := get(t, h, "/download/01arz3ndektsv4rrffq69g5fff"); rr.Code != http.StatusNotFound {
|
||||||
t.Fatalf("GET /download/999 = %d, want 404", rr.Code)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+208
-55
@@ -6,31 +6,33 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reviewer — операции ревью и раскладки (worker.Worker).
|
// Reviewer — операции ревью и раскладки (worker.Worker).
|
||||||
type Reviewer interface {
|
type Reviewer interface {
|
||||||
ReviewData(ctx context.Context, id int64) (*worker.ReviewData, error)
|
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
|
||||||
Apply(ctx context.Context, id int64) error
|
Apply(ctx context.Context, id string) error
|
||||||
Refine(ctx context.Context, id int64, hint string) error
|
Refine(ctx context.Context, id string, hint string) error
|
||||||
SetType(ctx context.Context, id int64, mediaType string) error
|
IgnoreFile(ctx context.Context, id string, src string) error
|
||||||
IgnoreFile(ctx context.Context, id int64, src string) error
|
Defer(ctx context.Context, id string) error
|
||||||
Defer(ctx context.Context, id int64) error
|
Undo(ctx context.Context, id string) error
|
||||||
Undo(ctx context.Context, id int64) error
|
Relink(ctx context.Context, id string) error
|
||||||
Relink(ctx context.Context, id int64) error
|
Rerecognize(ctx context.Context, id string) error
|
||||||
Rerecognize(ctx context.Context, id int64) error
|
ChooseCandidate(ctx context.Context, id, candidateID string) error
|
||||||
ChooseCandidate(ctx context.Context, id, candidateID int64) error
|
SetProviderID(ctx context.Context, id string, provider, providerID string) error
|
||||||
SetProviderID(ctx context.Context, id int64, provider, providerID string) error
|
AddManualSource(ctx context.Context, id, provider, providerID string) error
|
||||||
ClearProvider(ctx context.Context, id int64) error
|
ClearProvider(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Представление страницы ревью ---
|
// --- Представление страницы ревью ---
|
||||||
|
|
||||||
type reviewView struct {
|
type reviewView struct {
|
||||||
ID int64
|
ID string
|
||||||
Source string
|
Source string
|
||||||
Context string
|
Context string
|
||||||
State string
|
State string
|
||||||
@@ -41,6 +43,7 @@ type reviewView struct {
|
|||||||
Title string
|
Title string
|
||||||
OriginalTitle string
|
OriginalTitle string
|
||||||
Year int
|
Year int
|
||||||
|
SeasonSummary string // сводка сезонов для сериала (пусто для фильма)
|
||||||
Provider string
|
Provider string
|
||||||
ProviderID string
|
ProviderID string
|
||||||
MatchURL string // ссылка на подтверждённую запись метабазы (пусто — текстом)
|
MatchURL string // ссылка на подтверждённую запись метабазы (пусто — текстом)
|
||||||
@@ -49,25 +52,32 @@ type reviewView struct {
|
|||||||
Hints []string
|
Hints []string
|
||||||
Files []fileRow
|
Files []fileRow
|
||||||
HasPlan bool
|
HasPlan bool
|
||||||
HasLinks bool // есть хотя бы один целевой путь → можно применять
|
HasLinks bool // есть хотя бы один целевой путь → можно применять
|
||||||
NoBase bool // выбрано «без базы»
|
NoBase bool // выбрано «без базы»
|
||||||
Candidates []candidateView
|
Sources []sourceView // единый список источников совпадения
|
||||||
|
BlockError string // ошибка выбора внутри блока (htmx); не путать с Error (?err=)
|
||||||
}
|
}
|
||||||
|
|
||||||
type candidateView struct {
|
// sourceView — строка единого списка источников на экране ревью: нейронка или
|
||||||
ID int64
|
// кандидат базы. Инфо и предпросмотр раскладки показываются для активного
|
||||||
Provider string
|
// источника из верхнеуровневых полей reviewView, поэтому per-source превью
|
||||||
ProviderID string
|
// строка не несёт.
|
||||||
Title string
|
type sourceView struct {
|
||||||
Year int
|
Kind string // "neural" | "candidate"
|
||||||
URL string
|
CandidateID string
|
||||||
Chosen bool
|
Provider string
|
||||||
|
ProviderID string
|
||||||
|
Title string
|
||||||
|
Year int
|
||||||
|
MatchURL string
|
||||||
|
Active bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := pathID(r)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "некорректный id", http.StatusBadRequest)
|
// Невалидный id = несуществующая сущность; в БД не ходим.
|
||||||
|
http.Error(w, "задача не найдена", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||||
@@ -81,12 +91,20 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
view := reviewView{
|
||||||
ID: id,
|
ID: id,
|
||||||
Source: shorten(rd.Download.SourceRef, 80),
|
Source: shorten(rd.Download.SourceRef, 80),
|
||||||
Context: rd.Download.Context,
|
Context: rd.Download.Context,
|
||||||
State: string(rd.Download.State),
|
State: string(rd.Download.State),
|
||||||
Error: r.URL.Query().Get("err"),
|
Error: errMsg,
|
||||||
StateError: rd.Download.ErrorMsg.String,
|
StateError: rd.Download.ErrorMsg.String,
|
||||||
Hints: rd.Hints,
|
Hints: rd.Hints,
|
||||||
}
|
}
|
||||||
@@ -96,6 +114,9 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
view.Title = rd.Plan.Title
|
view.Title = rd.Plan.Title
|
||||||
view.OriginalTitle = rd.Plan.OriginalTitle
|
view.OriginalTitle = rd.Plan.OriginalTitle
|
||||||
view.Year = rd.Plan.Year
|
view.Year = rd.Plan.Year
|
||||||
|
if view.IsSeries {
|
||||||
|
view.SeasonSummary = seasonSummary(rd.Plan)
|
||||||
|
}
|
||||||
view.Reasons = rec.ReasonList()
|
view.Reasons = rec.ReasonList()
|
||||||
switch rd.Provider {
|
switch rd.Provider {
|
||||||
case "", "none":
|
case "", "none":
|
||||||
@@ -111,20 +132,23 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
view.Files = buildFileRows(rd.Plan, rd.Preview)
|
view.Files = buildFileRows(rd.Plan, rd.Preview)
|
||||||
view.HasPlan = len(rd.Plan.Files) > 0
|
view.HasPlan = len(rd.Plan.Files) > 0
|
||||||
view.HasLinks = len(rd.Preview) > 0
|
view.HasLinks = len(rd.Preview) > 0
|
||||||
for _, c := range rd.Candidates {
|
for _, src := range rd.Sources {
|
||||||
view.Candidates = append(view.Candidates, candidateView{
|
sv := sourceView{
|
||||||
ID: c.ID,
|
Kind: string(src.Kind),
|
||||||
Provider: c.Provider,
|
CandidateID: src.CandidateID,
|
||||||
ProviderID: c.ProviderID,
|
Provider: src.Provider,
|
||||||
Title: c.Title.String,
|
ProviderID: src.ProviderID,
|
||||||
Year: int(c.Year.Int64),
|
Title: src.Title,
|
||||||
URL: c.URL.String,
|
Year: src.Year,
|
||||||
Chosen: c.Chosen,
|
Active: src.Active,
|
||||||
})
|
}
|
||||||
|
if src.Kind == worker.SourceCandidate {
|
||||||
|
sv.MatchURL = sourceMatchURL(src)
|
||||||
|
}
|
||||||
|
view.Sources = append(view.Sources, sv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return view
|
||||||
s.render(w, "review.html", view)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Действия ревью (POST → redirect) ---
|
// --- Действия ревью (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) {
|
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()
|
_ = r.ParseForm()
|
||||||
return s.deps.Reviewer.Refine(ctx, id, r.PostForm.Get("hint"))
|
return s.deps.Reviewer.Refine(ctx, id, r.PostForm.Get("hint"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleRerecognize(w http.ResponseWriter, r *http.Request) {
|
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)
|
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) {
|
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()
|
_ = r.ParseForm()
|
||||||
return s.deps.Reviewer.IgnoreFile(ctx, id, r.PostForm.Get("src"))
|
return s.deps.Reviewer.IgnoreFile(ctx, id, r.PostForm.Get("src"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
|
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()
|
_ = 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 {
|
if err != nil {
|
||||||
return errInvalidCandidate
|
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) {
|
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()
|
_ = r.ParseForm()
|
||||||
return s.deps.Reviewer.SetProviderID(ctx, id, r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
|
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) {
|
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)
|
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 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) {
|
func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := pathID(r)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -240,7 +351,7 @@ func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// reviewAction — общий помощник: выполнить действие и вернуться на страницу
|
// reviewAction — общий помощник: выполнить действие и вернуться на страницу
|
||||||
// ревью (с ошибкой в ?err при неудаче).
|
// ревью (с ошибкой в ?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)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
redirectErr(w, r, "некорректный id")
|
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, "")
|
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
|
// matchURL выбирает ссылку на подтверждённую запись метабазы. Приоритет — URL
|
||||||
// выбранного кандидата, но только если его provider+id совпадают с эффективными
|
// выбранного кандидата, но только если его provider+id совпадают с эффективными
|
||||||
// (человек мог выбрать кандидата, затем вручную переопределить 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) {
|
func redirectReview(w http.ResponseWriter, r *http.Request, id string, msg string) {
|
||||||
u := "/review/" + strconv.FormatInt(id, 10)
|
u := "/review/" + id
|
||||||
if msg != "" {
|
if msg != "" {
|
||||||
u += "?err=" + url.QueryEscape(msg)
|
u += "?err=" + url.QueryEscape(msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
-24
@@ -24,9 +24,17 @@ const errCodeQbitAdd = "qbit_add"
|
|||||||
|
|
||||||
// Store — нужная ingest часть хранилища.
|
// Store — нужная ingest часть хранилища.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
|
// FindActiveByInfohash — быстрый читающий дедуп-чек (до вызова LLM-namer);
|
||||||
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
|
// авторитетная проверка — внутри CreateDownloadIfNoActive.
|
||||||
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
|
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.
|
// QBittorrent — нужная ingest часть клиента qBittorrent.
|
||||||
@@ -58,7 +66,7 @@ type Service struct {
|
|||||||
// не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не
|
// не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не
|
||||||
// попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest
|
// попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest
|
||||||
// сам; closure избавляет ядро приёма от зависимости на пакет worker.
|
// сам; closure избавляет ядро приёма от зависимости на пакет worker.
|
||||||
notifyFailed func(downloadID int64)
|
notifyFailed func(downloadID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не
|
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не
|
||||||
@@ -68,7 +76,7 @@ func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *S
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetFailureNotifier подключает пинг о падении приёма (до начала работы).
|
// SetFailureNotifier подключает пинг о падении приёма (до начала работы).
|
||||||
func (s *Service) SetFailureNotifier(fn func(downloadID int64)) { s.notifyFailed = fn }
|
func (s *Service) SetFailureNotifier(fn func(downloadID string)) { s.notifyFailed = fn }
|
||||||
|
|
||||||
// Request — входной запрос приёма.
|
// Request — входной запрос приёма.
|
||||||
type Request struct {
|
type Request struct {
|
||||||
@@ -78,8 +86,8 @@ type Request struct {
|
|||||||
|
|
||||||
// Result — итог приёма.
|
// Result — итог приёма.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
DownloadID int64
|
DownloadID string
|
||||||
Infohash string
|
Infohashes []string // все хеши источника (гибридный magnet: v1 и v2, v1 первым)
|
||||||
State store.State
|
State store.State
|
||||||
Deduplicated bool // присоединились к уже активной задаче, нового добавления не было
|
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)
|
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
|
||||||
ctx = logctx.With(ctx, log)
|
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)
|
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
||||||
} else if existing != nil {
|
} else if existing != nil {
|
||||||
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||||||
return Result{
|
return s.attached(ctx, info, existing), nil
|
||||||
DownloadID: existing.ID,
|
|
||||||
Infohash: info.Infohash,
|
|
||||||
State: existing.State,
|
|
||||||
Deduplicated: true,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отображаемое имя для списка qBit — best-effort: не валит приём.
|
// Отображаемое имя для списка qBit — best-effort: не валит приём.
|
||||||
@@ -122,18 +128,30 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
d := &store.Download{
|
d := &store.Download{
|
||||||
SourceType: store.SourceMagnet,
|
SourceType: store.SourceMagnet,
|
||||||
SourceRef: source,
|
SourceRef: source,
|
||||||
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
|
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
|
||||||
Context: req.Context,
|
Context: req.Context,
|
||||||
Infohash: store.NullString(info.Infohash),
|
State: store.StateDownloading,
|
||||||
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 {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("ingest: create download: %w", err)
|
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)
|
log = log.With("download_id", id)
|
||||||
ctx = logctx.With(ctx, log)
|
ctx = logctx.With(ctx, log)
|
||||||
|
|
||||||
@@ -156,14 +174,33 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
|||||||
// сами, чтобы приёмные провалы тоже доходили до автора.
|
// сами, чтобы приёмные провалы тоже доходили до автора.
|
||||||
go s.notifyFailed(id)
|
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)
|
fmt.Errorf("ingest: add to qbittorrent: %w", addErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("download accepted", "category", s.cfg.Category)
|
log.Info("download accepted", "category", s.cfg.Category)
|
||||||
return Result{
|
return Result{
|
||||||
DownloadID: id,
|
DownloadID: id,
|
||||||
Infohash: info.Infohash,
|
Infohashes: info.Infohashes,
|
||||||
State: store.StateDownloading,
|
State: store.StateDownloading,
|
||||||
}, nil
|
}, 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
)
|
)
|
||||||
@@ -19,29 +20,39 @@ const sampleInfohash = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
|||||||
type fakeStore struct {
|
type fakeStore struct {
|
||||||
active *store.Download
|
active *store.Download
|
||||||
created []store.Download
|
created []store.Download
|
||||||
nextID int64
|
hashes [][]string
|
||||||
|
toppedUp []string
|
||||||
stateCalls []stateCall
|
stateCalls []stateCall
|
||||||
}
|
}
|
||||||
|
|
||||||
type stateCall struct {
|
type stateCall struct {
|
||||||
id int64
|
id string
|
||||||
state store.State
|
state store.State
|
||||||
code string
|
code string
|
||||||
msg 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
|
return f.active, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
|
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
||||||
f.nextID++
|
if f.active != nil {
|
||||||
d.ID = f.nextID
|
return f.active, nil
|
||||||
|
}
|
||||||
|
d.ID = ident.NewID()
|
||||||
f.created = append(f.created, *d)
|
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})
|
f.stateCalls = append(f.stateCalls, stateCall{id, st, code, msg})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -90,8 +101,8 @@ func TestIngestHappyPath(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Ingest: %v", err)
|
t.Fatalf("Ingest: %v", err)
|
||||||
}
|
}
|
||||||
if res.Infohash != sampleInfohash {
|
if len(res.Infohashes) != 1 || res.Infohashes[0] != sampleInfohash {
|
||||||
t.Errorf("infohash = %q", res.Infohash)
|
t.Errorf("infohashes = %v", res.Infohashes)
|
||||||
}
|
}
|
||||||
if res.State != store.StateDownloading || res.Deduplicated {
|
if res.State != store.StateDownloading || res.Deduplicated {
|
||||||
t.Errorf("res = %+v", res)
|
t.Errorf("res = %+v", res)
|
||||||
@@ -99,9 +110,12 @@ func TestIngestHappyPath(t *testing.T) {
|
|||||||
if len(fs.created) != 1 {
|
if len(fs.created) != 1 {
|
||||||
t.Fatalf("создано задач: %d, want 1", len(fs.created))
|
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)
|
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 {
|
if len(fq.added) != 1 {
|
||||||
t.Fatalf("вызовов qbt.Add: %d, want 1", len(fq.added))
|
t.Fatalf("вызовов qbt.Add: %d, want 1", len(fq.added))
|
||||||
}
|
}
|
||||||
@@ -149,15 +163,15 @@ func TestIngestEmptyNameOmitsRename(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestIdempotent(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}
|
fs := &fakeStore{active: existing}
|
||||||
fq := &fakeQbt{}
|
fq := &fakeQbt{}
|
||||||
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet})
|
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Ingest: %v", err)
|
t.Fatalf("Ingest: %v", err)
|
||||||
}
|
}
|
||||||
if !res.Deduplicated || res.DownloadID != 7 {
|
if !res.Deduplicated || res.DownloadID != existing.ID {
|
||||||
t.Errorf("ожидалось присоединение к задаче 7: %+v", res)
|
t.Errorf("ожидалось присоединение к существующей задаче: %+v", res)
|
||||||
}
|
}
|
||||||
if len(fs.created) != 0 {
|
if len(fs.created) != 0 {
|
||||||
t.Error("не должно создаваться новой задачи")
|
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) {
|
func TestIngestQbitErrorMarksFailed(t *testing.T) {
|
||||||
fs := &fakeStore{}
|
fs := &fakeStore{}
|
||||||
fq := &fakeQbt{err: errors.New("connection refused")}
|
fq := &fakeQbt{err: errors.New("connection refused")}
|
||||||
@@ -186,16 +223,16 @@ func TestIngestQbitErrorNotifies(t *testing.T) {
|
|||||||
fs := &fakeStore{}
|
fs := &fakeStore{}
|
||||||
fq := &fakeQbt{err: errors.New("connection refused")}
|
fq := &fakeQbt{err: errors.New("connection refused")}
|
||||||
svc := newService(fs, fq)
|
svc := newService(fs, fq)
|
||||||
got := make(chan int64, 1)
|
got := make(chan string, 1)
|
||||||
svc.SetFailureNotifier(func(id int64) { got <- id })
|
svc.SetFailureNotifier(func(id string) { got <- id })
|
||||||
|
|
||||||
if _, err := svc.Ingest(context.Background(), Request{Source: sampleMagnet}); err == nil {
|
if _, err := svc.Ingest(context.Background(), Request{Source: sampleMagnet}); err == nil {
|
||||||
t.Fatal("ожидалась ошибка")
|
t.Fatal("ожидалась ошибка")
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case id := <-got:
|
case id := <-got:
|
||||||
if id == 0 {
|
if id == "" {
|
||||||
t.Errorf("уведомление с нулевым id")
|
t.Errorf("уведомление с пустым id")
|
||||||
}
|
}
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
t.Fatal("уведомление о падении приёма не пришло")
|
t.Fatal("уведомление о падении приёма не пришло")
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ const (
|
|||||||
type Result struct {
|
type Result struct {
|
||||||
Link Link
|
Link Link
|
||||||
Status LinkStatus
|
Status LinkStatus
|
||||||
|
Size int64 // размер разложенного файла (байт); 0, если stat не удался
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrCollision — цель существует и это другой файл (нужен review).
|
// ErrCollision — цель существует и это другой файл (нужен review).
|
||||||
@@ -261,7 +262,17 @@ func (l *Layouter) Apply(ctx context.Context, links []Link) ([]Result, error) {
|
|||||||
}
|
}
|
||||||
log.Debug("layout link applied",
|
log.Debug("layout link applied",
|
||||||
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "status", status)
|
"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
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,9 @@ func TestApply_CreatesHardlink(t *testing.T) {
|
|||||||
if len(res) != 1 || res[0].Status != StatusLinked {
|
if len(res) != 1 || res[0].Status != StatusLinked {
|
||||||
t.Fatalf("res = %+v", res)
|
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, источник цел.
|
// Тот же inode, источник цел.
|
||||||
si, _ := os.Stat(src)
|
si, _ := os.Stat(src)
|
||||||
di, _ := os.Stat(links[0].Dst)
|
di, _ := os.Stat(links[0].Dst)
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import (
|
|||||||
|
|
||||||
// Info — разобранная magnet-ссылка.
|
// Info — разобранная magnet-ссылка.
|
||||||
type Info struct {
|
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 — человекочитаемое имя, если задано
|
DisplayName string // dn — человекочитаемое имя, если задано
|
||||||
Trackers []string // tr — трекеры
|
Trackers []string // tr — трекеры
|
||||||
}
|
}
|
||||||
@@ -49,16 +50,19 @@ func Parse(raw string) (Info, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
infohash := v1
|
var hashes []string
|
||||||
if infohash == "" {
|
for _, h := range []string{v1, v2} {
|
||||||
infohash = 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{}, fmt.Errorf("magnet without a usable infohash (xt)")
|
||||||
}
|
}
|
||||||
|
|
||||||
return Info{
|
return Info{
|
||||||
Infohash: infohash,
|
Infohash: hashes[0],
|
||||||
|
Infohashes: hashes,
|
||||||
DisplayName: vals.Get("dn"),
|
DisplayName: vals.Get("dn"),
|
||||||
Trackers: vals["tr"],
|
Trackers: vals["tr"],
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -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) {
|
func TestParseErrors(t *testing.T) {
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"https://example.com/file.torrent", // не magnet
|
"https://example.com/file.torrent", // не magnet
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
|||||||
ID: r.TVDBID,
|
ID: r.TVDBID,
|
||||||
Title: r.Name,
|
Title: r.Name,
|
||||||
Year: year,
|
Year: year,
|
||||||
URL: "https://www.thetvdb.com/dereferrer/series/" + r.TVDBID,
|
URL: "https://www.thetvdb.com/dereferrer/" + typ + "/" + r.TVDBID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -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) {
|
func TestTVDB_SeasonEpisodeCounts(t *testing.T) {
|
||||||
srv := fakeTVDB(t, nil)
|
srv := fakeTVDB(t, nil)
|
||||||
counts, err := newTVDB(t, srv.URL).SeasonEpisodeCounts(context.Background(), "269613")
|
counts, err := newTVDB(t, srv.URL).SeasonEpisodeCounts(context.Background(), "269613")
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ type Torrent struct {
|
|||||||
Tags string `json:"tags"` // через запятую
|
Tags string `json:"tags"` // через запятую
|
||||||
Progress float64 `json:"progress"`
|
Progress float64 `json:"progress"`
|
||||||
AmountLeft int64 `json:"amount_left"`
|
AmountLeft int64 `json:"amount_left"`
|
||||||
|
TotalSize int64 `json:"total_size"` // полный размер раздачи, байт
|
||||||
AddedOn int64 `json:"added_on"`
|
AddedOn int64 `json:"added_on"`
|
||||||
InfohashV1 string `json:"infohash_v1"`
|
InfohashV1 string `json:"infohash_v1"`
|
||||||
InfohashV2 string `json:"infohash_v2"`
|
InfohashV2 string `json:"infohash_v2"`
|
||||||
|
|||||||
+398
-89
@@ -8,6 +8,10 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||||
)
|
)
|
||||||
|
|
||||||
// State — состояние загрузки в машине состояний (см. architecture.md).
|
// State — состояние загрузки в машине состояний (см. architecture.md).
|
||||||
@@ -35,27 +39,26 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// terminalStates — единый список окончательно остановленных состояний:
|
// terminalStates — единый список окончательно остановленных состояний:
|
||||||
// источник истины и для IsTerminal, и для выборки «активных» задач
|
// источник истины и для IsTerminal, и для выборки «активных» задач.
|
||||||
// (FindActiveByInfohash). Любое новое терминальное состояние добавляется
|
// Активность выводится ТОЛЬКО из state (отдельного ключа идемпотентности
|
||||||
// ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key
|
// нет); любое новое терминальное состояние добавляется ТОЛЬКО сюда — иначе
|
||||||
// снимается по IsTerminal, а активность считалась бы по другому списку).
|
// семантика «активности» разъедется.
|
||||||
//
|
//
|
||||||
// Состояния рассинхрона (target_missing/orphaned/deleted), а также
|
// Состояния рассинхрона (target_missing/orphaned/deleted), а также failed —
|
||||||
// failed/stuck — терминальны для idempotency_key, но не «мертвы»: дальше
|
// терминальны для активности, но не «мертвы»: дальше двигает либо человек
|
||||||
// двигает либо человек (relink из target_missing, retry из failed/stuck), либо
|
// (relink из target_missing, retry из failed), либо фоновая сверка (healing/
|
||||||
// фоновая сверка (healing/прогрессия desync; авто-восстановление failed/stuck
|
// прогрессия desync; авто-восстановление при оживлении источника, см.
|
||||||
// при оживлении источника, см. state-reconciliation) — напрямую через
|
// state-reconciliation) — через ActivateIfNoOtherActive, который атомарно
|
||||||
// SetDownloadState, который восстановит ключ для нетерминального целевого
|
// проверяет инвариант «не более одной активной загрузки на infohash».
|
||||||
// состояния.
|
|
||||||
var terminalStates = []State{
|
var terminalStates = []State{
|
||||||
StateDone, StateCancelled, StateFailed, StateReverted,
|
StateDone, StateCancelled, StateFailed, StateReverted,
|
||||||
StateTargetMissing, StateOrphaned, StateDeleted,
|
StateTargetMissing, StateOrphaned, StateDeleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsTerminal сообщает, завершена ли задача окончательно. Для терминальных
|
// IsTerminal сообщает, завершена ли задача окончательно. Терминальная задача
|
||||||
// состояний снимается ключ идемпотентности — тот же infohash можно завести
|
// не «владеет» своими инфохэшами — тот же infohash можно завести заново
|
||||||
// заново новой задачей (см. architecture.md, «повторное добавление»).
|
// новой задачей (см. architecture.md, «повторное добавление»). stuck
|
||||||
// stuck терминальным не считается: задача восстановима (retry).
|
// терминальным не считается: задача восстановима (retry).
|
||||||
func (s State) IsTerminal() bool {
|
func (s State) IsTerminal() bool {
|
||||||
return slices.Contains(terminalStates, s)
|
return slices.Contains(terminalStates, s)
|
||||||
}
|
}
|
||||||
@@ -69,50 +72,101 @@ const (
|
|||||||
SourceURL SourceType = "url"
|
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.
|
// Download — строка таблицы download.
|
||||||
type Download struct {
|
type Download struct {
|
||||||
ID int64 `db:"id"`
|
ID string `db:"id"` // ULID (lowercase), публичный ключ домена
|
||||||
SourceType SourceType `db:"source_type"`
|
SourceType SourceType `db:"source_type"`
|
||||||
SourceRef string `db:"source_ref"`
|
SourceRef string `db:"source_ref"`
|
||||||
DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI
|
DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI
|
||||||
Context string `db:"context"`
|
Context string `db:"context"`
|
||||||
Infohash sql.NullString `db:"infohash"`
|
State State `db:"state"`
|
||||||
IdempotencyKey sql.NullString `db:"idempotency_key"`
|
ErrorCode sql.NullString `db:"error_code"`
|
||||||
State State `db:"state"`
|
ErrorMsg sql.NullString `db:"error_msg"`
|
||||||
ErrorCode sql.NullString `db:"error_code"`
|
|
||||||
ErrorMsg sql.NullString `db:"error_msg"`
|
|
||||||
// SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в
|
// SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в
|
||||||
// qBittorrent (дебаунс пропажи источника, см. state-reconciliation).
|
// qBittorrent (дебаунс пропажи источника, см. state-reconciliation).
|
||||||
SourceMissCount int `db:"source_miss_count"`
|
SourceMissCount int `db:"source_miss_count"`
|
||||||
// SourceAddedAt — время добавления торрента в qBittorrent (added_on), базис
|
// SourceAddedAt — время добавления торрента в qBittorrent (added_on), базис
|
||||||
// сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в
|
// сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в
|
||||||
// формате sqliteTimeLayout (UTC), как created_at.
|
// формате RFC 3339 (UTC, суффикс Z), как created_at.
|
||||||
SourceAddedAt sql.NullString `db:"source_added_at"`
|
SourceAddedAt sql.NullString `db:"source_added_at"`
|
||||||
CreatedAt string `db:"created_at"`
|
CreatedAt string `db:"created_at"`
|
||||||
UpdatedAt string `db:"updated_at"`
|
UpdatedAt string `db:"updated_at"`
|
||||||
|
|
||||||
|
// Infohashes — хеши загрузки (download_infohash); подгружаются вместе с
|
||||||
|
// записью методами чтения store (v1 раньше v2 — порядок стабильный).
|
||||||
|
Infohashes []Infohash `db:"-"`
|
||||||
|
|
||||||
// RecTitle — распознанное название текущей попытки (LEFT JOIN recognition).
|
// RecTitle — распознанное название текущей попытки (LEFT JOIN recognition).
|
||||||
// Заполняется только листингом ListDownloadsPage для фолбека заголовка; в
|
// Заполняется только листингом ListDownloadsPage для фолбека заголовка; в
|
||||||
// прочих выборках остаётся пустым.
|
// прочих выборках остаётся пустым.
|
||||||
RecTitle sql.NullString `db:"rec_title"`
|
RecTitle sql.NullString `db:"rec_title"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC).
|
// HashList — все хеши загрузки списком (для сопоставления с qBittorrent).
|
||||||
const sqliteTimeLayout = "2006-01-02 15:04:05"
|
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) {
|
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) —
|
// FormatTime форматирует время в формат меток хранилища — RFC 3339 в UTC
|
||||||
// байт-в-байт тот же, что у created_at/updated_at. Нужен, чтобы лексикографи-
|
// (суффикс Z), напр. «2006-01-02T15:04:05Z». Единый формат всех меток; DEFAULT
|
||||||
// ческое сравнение строк времени (COALESCE(source_added_at, created_at) в
|
// в схеме нет, время всегда пишет приложение через Now (см. ниже). Фиксированная
|
||||||
// сортировке списка) давало верный хронологический порядок.
|
// ширина сохраняет лексикографическое сравнение строк времени = хронологию
|
||||||
|
// (COALESCE(source_added_at, created_at) в сортировке списка).
|
||||||
func FormatTime(t time.Time) string {
|
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).
|
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
|
||||||
func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) }
|
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 != ""}
|
return sql.NullString{String: s, Valid: s != ""}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateDownload вставляет загрузку и возвращает её id.
|
// CreateDownloadIfNoActive атомарно (одна write-транзакция, BEGIN IMMEDIATE
|
||||||
func (s *Store) CreateDownload(ctx context.Context, d *Download) (int64, error) {
|
// через _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")
|
||||||
|
}
|
||||||
|
now := FormatTime(Now())
|
||||||
|
|
||||||
|
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create download: begin tx: %w", err)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
d.ID = ident.NewID()
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO download (source_type, source_ref, display_name, context, infohash, idempotency_key, state)
|
INSERT INTO download (id, source_type, source_ref, display_name, context, state, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
res, err := s.DB.ExecContext(ctx, q,
|
if _, err := tx.ExecContext(ctx, q,
|
||||||
d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.Infohash, d.IdempotencyKey, d.State)
|
d.ID, d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.State, now, now); err != nil {
|
||||||
if err != nil {
|
return nil, fmt.Errorf("insert download: %w", err)
|
||||||
return 0, fmt.Errorf("insert download: %w", err)
|
|
||||||
}
|
}
|
||||||
id, err := res.LastInsertId()
|
d.Infohashes = d.Infohashes[:0]
|
||||||
if err != nil {
|
for _, h := range norm {
|
||||||
return 0, fmt.Errorf("download last insert id: %w", err)
|
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)})
|
||||||
}
|
}
|
||||||
return id, nil
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("create download: commit: %w", err)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDownload возвращает загрузку по id.
|
// ActivateIfNoOtherActive атомарно возвращает загрузку в активное состояние
|
||||||
func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
|
// (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
|
var d Download
|
||||||
if err := s.DB.GetContext(ctx, &d, `SELECT * FROM download WHERE id = ?`, id); err != nil {
|
if err := s.DB.GetContext(ctx, &d, `SELECT * FROM download WHERE id = ?`, id); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
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
|
return &d, nil
|
||||||
}
|
}
|
||||||
@@ -153,20 +341,24 @@ func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
|
|||||||
// SetSourceAddedAt однократно фиксирует время добавления торрента в источник
|
// SetSourceAddedAt однократно фиксирует время добавления торрента в источник
|
||||||
// (qBittorrent added_on). SQL-гард `source_added_at IS NULL` не даёт перезапи-
|
// (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`
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListDownloads возвращает все загрузки, новые сверху.
|
// ListDownloads возвращает все загрузки, новые сверху (id — ULID, сортировка
|
||||||
|
// по нему хронологична).
|
||||||
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
|
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
|
||||||
var out []Download
|
var out []Download
|
||||||
if err := s.DB.SelectContext(ctx, &out, `SELECT * FROM download ORDER BY id DESC`); err != nil {
|
if err := s.DB.SelectContext(ctx, &out, `SELECT * FROM download ORDER BY id DESC`); err != nil {
|
||||||
return nil, fmt.Errorf("list downloads: %w", err)
|
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
|
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 {
|
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
|
||||||
return nil, fmt.Errorf("list downloads by state: %w", err)
|
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
|
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 {
|
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
|
||||||
return nil, fmt.Errorf("list recoverable: %w", err)
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindActiveByInfohash возвращает незавершённую задачу для infohash либо
|
// FindActiveByInfohash возвращает незавершённую задачу, владеющую любым из
|
||||||
// (nil, nil), если её нет. Основа идемпотентного приёма.
|
// hashes, либо (nil, nil). Читающая основа дедупа; сам инвариант держат
|
||||||
func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) {
|
// guarded-методы (CreateDownloadIfNoActive / ActivateIfNoOtherActive).
|
||||||
// «Активна» = не в терминальном состоянии. Список — единый с IsTerminal
|
func (s *Store) FindActiveByInfohash(ctx context.Context, hashes ...string) (*Download, error) {
|
||||||
// (terminalStates), иначе семантика активности разъедется с idempotency_key.
|
d, err := findActiveByInfohash(ctx, s.DB, normalizeHashes(hashes), "")
|
||||||
ph := make([]string, len(terminalStates))
|
if err != nil {
|
||||||
args := make([]any, 0, len(terminalStates)+1)
|
return nil, err
|
||||||
args = append(args, infohash)
|
|
||||||
for i, st := range terminalStates {
|
|
||||||
ph[i] = "?"
|
|
||||||
args = append(args, string(st))
|
|
||||||
}
|
}
|
||||||
q := `SELECT * FROM download WHERE infohash = ? AND state NOT IN (` +
|
if d != nil {
|
||||||
strings.Join(ph, ",") + `) ORDER BY id DESC LIMIT 1`
|
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
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -238,63 +460,150 @@ func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Dow
|
|||||||
return &d, nil
|
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 сообщает, есть ли хоть одна загрузка (в любом состоянии)
|
// 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
|
var n int
|
||||||
if err := s.DB.GetContext(ctx, &n,
|
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 false, fmt.Errorf("exists by infohash: %w", err)
|
||||||
}
|
}
|
||||||
return n > 0, nil
|
return n > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloadState переводит загрузку в новое состояние. Ключ
|
// SetDownloadState переводит загрузку в новое состояние. Механический
|
||||||
// идемпотентности пересчитывается из текущего infohash: для терминального
|
// бэкстоп инварианта «одна активная загрузка на infohash» (заменяет
|
||||||
// состояния снимается (NULL), иначе равен infohash — так partial unique
|
// удалённый unique-индекс): переход из терминального состояния в активное
|
||||||
// index гарантирует не более одной активной задачи на infohash.
|
// этим методом отклоняется — возврат в активное идёт ТОЛЬКО через
|
||||||
func (s *Store) SetDownloadState(ctx context.Context, id int64, state State, errCode, errMsg string) error {
|
// ActivateIfNoOtherActive, который проверяет владение хешами.
|
||||||
const q = `
|
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
|
UPDATE download
|
||||||
SET state = ?,
|
SET state = ?,
|
||||||
error_code = ?,
|
error_code = ?,
|
||||||
error_msg = ?,
|
error_msg = ?,
|
||||||
idempotency_key = CASE WHEN ? = 1 THEN NULL ELSE infohash END,
|
updated_at = ?
|
||||||
updated_at = datetime('now')
|
|
||||||
WHERE id = ?`
|
WHERE id = ?`
|
||||||
terminal := 0
|
args := []any{string(state), nullArg(errCode), nullArg(errMsg), FormatTime(Now()), id}
|
||||||
if state.IsTerminal() {
|
if !reviveOK && !state.IsTerminal() {
|
||||||
terminal = 1
|
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 {
|
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()
|
n, err := res.RowsAffected()
|
||||||
if err != nil {
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSourceMissCount записывает счётчик пропусков источника (дебаунс сверки).
|
// 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,
|
res, err := s.DB.ExecContext(ctx,
|
||||||
`UPDATE download SET source_miss_count = ? WHERE id = ?`, n, id)
|
`UPDATE download SET source_miss_count = ? WHERE id = ?`, n, id)
|
||||||
if err != nil {
|
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 {
|
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
|
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, не "").
|
// nullArg возвращает nil для пустой строки (чтобы писать NULL, не "").
|
||||||
func nullArg(s string) any {
|
func nullArg(s string) any {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
|
|||||||
+317
-41
@@ -2,6 +2,9 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,24 +18,39 @@ func newTestStore(t *testing.T) *Store {
|
|||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDownloading(infohash string) *Download {
|
func newDownloading() *Download {
|
||||||
return &Download{
|
return &Download{
|
||||||
SourceType: SourceMagnet,
|
SourceType: SourceMagnet,
|
||||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
SourceRef: "magnet:?xt=urn:btih:test",
|
||||||
Context: "ctx",
|
Context: "ctx",
|
||||||
Infohash: NullString(infohash),
|
State: StateDownloading,
|
||||||
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) {
|
func TestCreateAndGetDownload(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
id, err := st.CreateDownload(ctx, newDownloading("aabbccddeeff00112233445566778899aabbccdd"))
|
const ih = "AABBCCDDEEFF00112233445566778899aabbccdd" // смешанный регистр — нормализуется
|
||||||
if err != nil {
|
id := mustCreate(t, st, ih)
|
||||||
t.Fatalf("create: %v", err)
|
|
||||||
|
if len(id) != 26 || id != strings.ToLower(id) {
|
||||||
|
t.Errorf("id = %q, want lowercase ULID (26 chars)", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := st.GetDownload(ctx, id)
|
got, err := st.GetDownload(ctx, id)
|
||||||
@@ -48,6 +66,12 @@ func TestCreateAndGetDownload(t *testing.T) {
|
|||||||
if got.CreatedAt == "" {
|
if got.CreatedAt == "" {
|
||||||
t.Error("created_at пуст")
|
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) {
|
func TestFindActiveByInfohash(t *testing.T) {
|
||||||
@@ -59,10 +83,7 @@ func TestFindActiveByInfohash(t *testing.T) {
|
|||||||
t.Fatalf("ожидался (nil,nil), получили (%v,%v)", d, err)
|
t.Fatalf("ожидался (nil,nil), получили (%v,%v)", d, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := st.CreateDownload(ctx, newDownloading(ih))
|
id := mustCreate(t, st, ih)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
d, err := st.FindActiveByInfohash(ctx, ih)
|
d, err := st.FindActiveByInfohash(ctx, ih)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -70,6 +91,9 @@ func TestFindActiveByInfohash(t *testing.T) {
|
|||||||
if d == nil || d.ID != id {
|
if d == nil || d.ID != id {
|
||||||
t.Fatalf("активная задача не найдена: %v", d)
|
t.Fatalf("активная задача не найдена: %v", d)
|
||||||
}
|
}
|
||||||
|
if len(d.Infohashes) != 1 {
|
||||||
|
t.Fatalf("хеши не подгружены: %v", d.Infohashes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Состояния рассинхрона (target_missing/orphaned/deleted) терминальны: задача
|
// Состояния рассинхрона (target_missing/orphaned/deleted) терминальны: задача
|
||||||
@@ -81,12 +105,9 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
|
|||||||
t.Run(string(st), func(t *testing.T) {
|
t.Run(string(st), func(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
ih := "33333333333333333333333333333333333333" + string(st[0:2])
|
const ih = "3333333333333333333333333333333333333333"
|
||||||
|
|
||||||
id, err := store.CreateDownload(ctx, newDownloading(ih))
|
id := mustCreate(t, store, ih)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := store.SetDownloadState(ctx, id, st, "", ""); err != nil {
|
if err := store.SetDownloadState(ctx, id, st, "", ""); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -97,17 +118,15 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Терминальное состояние снимает ключ идемпотентности и позволяет завести
|
// Терминальное состояние освобождает infohash: тот же хеш заводится заново
|
||||||
// тот же infohash заново (повторная закачка спустя время).
|
// новой задачей (повторная закачка спустя время) — активность выводится
|
||||||
|
// только из state.
|
||||||
func TestTerminalReleasesInfohash(t *testing.T) {
|
func TestTerminalReleasesInfohash(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
const ih = "2222222222222222222222222222222222222222"
|
const ih = "2222222222222222222222222222222222222222"
|
||||||
|
|
||||||
id, err := st.CreateDownload(ctx, newDownloading(ih))
|
id := mustCreate(t, st, ih)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := st.SetDownloadState(ctx, id, StateFailed, "qbit_add", "boom"); err != nil {
|
if err := st.SetDownloadState(ctx, id, StateFailed, "qbit_add", "boom"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -116,39 +135,291 @@ func TestTerminalReleasesInfohash(t *testing.T) {
|
|||||||
if d, err := st.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
|
if d, err := st.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
|
||||||
t.Fatalf("после failed активная задача не должна находиться: (%v,%v)", d, err)
|
t.Fatalf("после failed активная задача не должна находиться: (%v,%v)", d, err)
|
||||||
}
|
}
|
||||||
// Ключ идемпотентности снят.
|
|
||||||
got, err := st.GetDownload(ctx, id)
|
got, err := st.GetDownload(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got.IdempotencyKey.Valid {
|
|
||||||
t.Errorf("idempotency_key должен быть NULL, получили %q", got.IdempotencyKey.String)
|
|
||||||
}
|
|
||||||
if got.ErrorCode.String != "qbit_add" {
|
if got.ErrorCode.String != "qbit_add" {
|
||||||
t.Errorf("error_code = %q", got.ErrorCode.String)
|
t.Errorf("error_code = %q", got.ErrorCode.String)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Тот же infohash заводится заново — unique index не мешает.
|
// Тот же infohash заводится заново новой задачей.
|
||||||
id2, err := st.CreateDownload(ctx, newDownloading(ih))
|
id2 := mustCreate(t, st, ih)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("повторное добавление после терминального должно проходить: %v", err)
|
|
||||||
}
|
|
||||||
if id2 == id {
|
if id2 == id {
|
||||||
t.Error("ожидалась новая задача")
|
t.Error("ожидалась новая задача")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Две активные задачи с одним ключом идемпотентности недопустимы.
|
// Повторный приём при активной задаче дедуплицируется: вторая вставка не
|
||||||
func TestActiveDuplicateRejected(t *testing.T) {
|
// создаёт строку, а возвращает существующую активную задачу.
|
||||||
|
func TestActiveDuplicateDeduplicated(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
const ih = "3333333333333333333333333333333333333333"
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err == nil {
|
if existing == nil || existing.ID != id {
|
||||||
t.Error("ожидалось нарушение уникальности idempotency_key")
|
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)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
id1, _ := st.CreateDownload(ctx, newDownloading("4444444444444444444444444444444444444444"))
|
id1 := mustCreate(t, st, "4444444444444444444444444444444444444444")
|
||||||
id2, _ := st.CreateDownload(ctx, newDownloading("5555555555555555555555555555555555555555"))
|
id2 := mustCreate(t, st, "5555555555555555555555555555555555555555")
|
||||||
if err := st.SetDownloadState(ctx, id2, StateCompleted, "", ""); err != nil {
|
if err := st.SetDownloadState(ctx, id2, StateCompleted, "", ""); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -169,6 +440,11 @@ func TestListAndByState(t *testing.T) {
|
|||||||
if len(all) != 2 {
|
if len(all) != 2 {
|
||||||
t.Fatalf("ListDownloads = %d, want 2", len(all))
|
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)
|
dl, err := st.ListDownloadsByState(ctx, StateDownloading)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -6,3 +6,9 @@ import "errors"
|
|||||||
// в него sql.ErrNoRows у источника, чтобы выше по коду не торчал database/sql,
|
// в него sql.ErrNoRows у источника, чтобы выше по коду не торчал database/sql,
|
||||||
// а потребители матчили причину через errors.Is(err, store.ErrNotFound).
|
// а потребители матчили причину через errors.Is(err, store.ErrNotFound).
|
||||||
var ErrNotFound = errors.New("not found")
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
// ErrInfohashTaken — инвариант «не более одной активной загрузки на infohash»:
|
||||||
|
// возврат задачи в активное состояние отклонён, потому что хешем уже владеет
|
||||||
|
// другая активная задача (ActivateIfNoOtherActive). Вызывающие отличают этот
|
||||||
|
// штатный конфликт от сбоя через errors.Is.
|
||||||
|
var ErrInfohashTaken = errors.New("infohash owned by another active download")
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func statesInGroup(g StateGroup) []State {
|
|||||||
// ListFilter — параметры страницы списка загрузок.
|
// ListFilter — параметры страницы списка загрузок.
|
||||||
type ListFilter struct {
|
type ListFilter struct {
|
||||||
Group StateGroup // группа состояний ("" / all — без сужения)
|
Group StateGroup // группа состояний ("" / all — без сужения)
|
||||||
Query string // поиск по названию/infohash/контексту (подстрока)
|
Query string // поиск по id/названию/infohash/контексту (подстрока)
|
||||||
IncludeDeleted bool // показывать состояние deleted (только под all)
|
IncludeDeleted bool // показывать состояние deleted (только под all)
|
||||||
Limit int // размер страницы
|
Limit int // размер страницы
|
||||||
Offset int // смещение страницы
|
Offset int // смещение страницы
|
||||||
@@ -71,11 +71,13 @@ func listWhere(f ListFilter) (string, []any) {
|
|||||||
// ICU в чистом modernc-драйвере нет. Достаточно для инфохэша/латиницы;
|
// ICU в чистом modernc-драйвере нет. Достаточно для инфохэша/латиницы;
|
||||||
// кириллические названия ищем как введены.
|
// кириллические названия ищем как введены.
|
||||||
like := "%" + q + "%"
|
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 display_name LIKE ? COLLATE NOCASE "+
|
||||||
"OR context LIKE ? COLLATE NOCASE "+
|
"OR context LIKE ? COLLATE NOCASE "+
|
||||||
"OR IFNULL(infohash,'') LIKE ? COLLATE NOCASE)")
|
"OR EXISTS (SELECT 1 FROM download_infohash dh "+
|
||||||
args = append(args, like, like, like, like)
|
"WHERE dh.download_id = download.id AND dh.infohash LIKE ? COLLATE NOCASE))")
|
||||||
|
args = append(args, like, like, like, like, like)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(conds) == 0 {
|
if len(conds) == 0 {
|
||||||
@@ -109,5 +111,8 @@ LIMIT ? OFFSET ?`
|
|||||||
if err := s.DB.SelectContext(ctx, &out, q, pageArgs...); err != nil {
|
if err := s.DB.SelectContext(ctx, &out, q, pageArgs...); err != nil {
|
||||||
return nil, 0, fmt.Errorf("list downloads page: %w", err)
|
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
|
return out, total, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-12
@@ -11,25 +11,28 @@ import (
|
|||||||
func hashN(n int) string { return fmt.Sprintf("%040x", n) }
|
func hashN(n int) string { return fmt.Sprintf("%040x", n) }
|
||||||
|
|
||||||
// mkDownload заводит загрузку в заданном состоянии с display_name.
|
// 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()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
d := newDownloading(hashN(n))
|
d := newDownloading()
|
||||||
d.DisplayName = display
|
d.DisplayName = display
|
||||||
id, err := st.CreateDownload(ctx, d)
|
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(n)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create #%d: %v", n, err)
|
t.Fatalf("create #%d: %v", n, err)
|
||||||
}
|
}
|
||||||
|
if existing != nil {
|
||||||
|
t.Fatalf("create #%d: unexpected dedup", n)
|
||||||
|
}
|
||||||
if state != StateDownloading {
|
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)
|
t.Fatalf("set state #%d: %v", n, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return id
|
return d.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
func ids(ds []Download) []int64 {
|
func ids(ds []Download) []string {
|
||||||
out := make([]int64, len(ds))
|
out := make([]string, len(ds))
|
||||||
for i, d := range ds {
|
for i, d := range ds {
|
||||||
out[i] = d.ID
|
out[i] = d.ID
|
||||||
}
|
}
|
||||||
@@ -51,7 +54,7 @@ func TestListDownloadsPageFilterAndDeleted(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if total != 1 || len(page) != 1 || page[0].ID != review {
|
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 скрыт по умолчанию.
|
// all: deleted скрыт по умолчанию.
|
||||||
@@ -110,7 +113,7 @@ func TestListDownloadsPageSearch(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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))
|
t.Fatalf("search by infohash = %v", ids(page))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,7 +147,7 @@ func TestListDownloadsPageOrderAndPagination(t *testing.T) {
|
|||||||
// id3 фолбечит на created_at (~сейчас, 2026-07-01) — свежее, чем добавления
|
// id3 фолбечит на created_at (~сейчас, 2026-07-01) — свежее, чем добавления
|
||||||
// id1/id2 в июне → id3 первым; затем id2 (позже добавлен), затем id1.
|
// id1/id2 в июне → id3 первым; затем id2 (позже добавлен), затем id1.
|
||||||
got := ids(page)
|
got := ids(page)
|
||||||
want := []int64{id3, id2, id1}
|
want := []string{id3, id2, id1}
|
||||||
for i := range want {
|
for i := range want {
|
||||||
if got[i] != want[i] {
|
if got[i] != want[i] {
|
||||||
t.Fatalf("порядок = %v, want %v", got, want)
|
t.Fatalf("порядок = %v, want %v", got, want)
|
||||||
@@ -178,7 +181,7 @@ func TestListDownloadsPageTieBreakByID(t *testing.T) {
|
|||||||
|
|
||||||
// Одинаковое время добавления у всех → устойчивый порядок по id DESC.
|
// Одинаковое время добавления у всех → устойчивый порядок по id DESC.
|
||||||
same := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
same := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||||
var idList []int64
|
var idList []string
|
||||||
for i := 1; i <= 3; i++ {
|
for i := 1; i <= 3; i++ {
|
||||||
id := mkDownload(t, st, i, StateDownloading, fmt.Sprintf("d%d", i))
|
id := mkDownload(t, st, i, StateDownloading, fmt.Sprintf("d%d", i))
|
||||||
if err := st.SetSourceAddedAt(ctx, id, same); err != nil {
|
if err := st.SetSourceAddedAt(ctx, id, same); err != nil {
|
||||||
@@ -191,7 +194,7 @@ func TestListDownloadsPageTieBreakByID(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
got := ids(page)
|
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 {
|
for i := range want {
|
||||||
if got[i] != want[i] {
|
if got[i] != want[i] {
|
||||||
t.Fatalf("tie-break порядок = %v, want %v", got, want)
|
t.Fatalf("tie-break порядок = %v, want %v", got, want)
|
||||||
|
|||||||
@@ -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)`,
|
||||||
|
}
|
||||||
@@ -7,12 +7,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Recognition — строка таблицы recognition (попытка распознавания).
|
// Recognition — строка таблицы recognition (попытка распознавания).
|
||||||
type Recognition struct {
|
type Recognition struct {
|
||||||
ID int64 `db:"id"`
|
ID string `db:"id"`
|
||||||
DownloadID int64 `db:"download_id"`
|
DownloadID string `db:"download_id"`
|
||||||
AttemptNo int `db:"attempt_no"`
|
AttemptNo int `db:"attempt_no"`
|
||||||
IsCurrent bool `db:"is_current"`
|
IsCurrent bool `db:"is_current"`
|
||||||
MediaType sql.NullString `db:"media_type"`
|
MediaType sql.NullString `db:"media_type"`
|
||||||
@@ -41,54 +43,51 @@ func (r Recognition) ReasonList() []string {
|
|||||||
// CreateRecognition вставляет новую попытку распознавания, помечая прежние
|
// CreateRecognition вставляет новую попытку распознавания, помечая прежние
|
||||||
// как неактуальные (is_current = 0) и проставляя следующий attempt_no.
|
// как неактуальные (is_current = 0) и проставляя следующий attempt_no.
|
||||||
// Возвращает id новой записи. reasons сериализуется в JSON.
|
// Возвращает 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)
|
reasonsJSON, err := json.Marshal(reasons)
|
||||||
if err != nil {
|
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)
|
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("begin tx: %w", err)
|
return "", fmt.Errorf("begin tx: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`UPDATE recognition SET is_current = 0 WHERE download_id = ?`, r.DownloadID); err != nil {
|
`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
|
var nextAttempt int
|
||||||
if err := tx.GetContext(ctx, &nextAttempt,
|
if err := tx.GetContext(ctx, &nextAttempt,
|
||||||
`SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM recognition WHERE download_id = ?`,
|
`SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM recognition WHERE download_id = ?`,
|
||||||
r.DownloadID); err != nil {
|
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 = `
|
const q = `
|
||||||
INSERT INTO recognition
|
INSERT INTO recognition
|
||||||
(download_id, attempt_no, is_current, media_type, title, original_title,
|
(id, download_id, attempt_no, is_current, media_type, title, original_title,
|
||||||
year, provider, provider_id, confidence, reasons, raw_llm, plan)
|
year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at)
|
||||||
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
res, err := tx.ExecContext(ctx, q,
|
if _, err := tx.ExecContext(ctx, q,
|
||||||
r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
|
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)
|
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan,
|
||||||
if err != nil {
|
FormatTime(Now())); err != nil {
|
||||||
return 0, fmt.Errorf("insert recognition: %w", err)
|
return "", fmt.Errorf("insert recognition: %w", err)
|
||||||
}
|
|
||||||
id, err := res.LastInsertId()
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("recognition last insert id: %w", err)
|
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
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 возвращает актуальную попытку распознавания загрузки
|
// GetCurrentRecognition возвращает актуальную попытку распознавания загрузки
|
||||||
// либо (nil, nil), если её ещё нет.
|
// либо (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
|
var r Recognition
|
||||||
err := s.DB.GetContext(ctx, &r,
|
err := s.DB.GetContext(ctx, &r,
|
||||||
`SELECT * FROM recognition WHERE download_id = ? AND is_current = 1
|
`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) ---
|
// --- Подсказки (hint) ---
|
||||||
|
|
||||||
// AddHint добавляет текстовую подсказку ревьюера к загрузке.
|
// 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,
|
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 fmt.Errorf("add hint: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListHints возвращает подсказки загрузки в хронологическом порядке.
|
// 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
|
var out []string
|
||||||
if err := s.DB.SelectContext(ctx, &out,
|
if err := s.DB.SelectContext(ctx, &out,
|
||||||
`SELECT text FROM hint WHERE download_id = ? ORDER BY id`, downloadID); err != nil {
|
`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) ---
|
// --- Ручные правки (override) ---
|
||||||
|
|
||||||
// SetOverride пиннит значение поля (upsert по (download_id, field)).
|
// 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 = `
|
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`
|
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 fmt.Errorf("set override %q: %w", field, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListOverrides возвращает запиненные правки загрузки как map[field]value.
|
// 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,
|
rows, err := s.DB.QueryxContext(ctx,
|
||||||
`SELECT field, value FROM override WHERE download_id = ?`, downloadID)
|
`SELECT field, value FROM override WHERE download_id = ?`, downloadID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -160,13 +160,14 @@ func (s *Store) ListOverrides(ctx context.Context, downloadID int64) (map[string
|
|||||||
|
|
||||||
// FileLink — строка таблицы file_link (одна созданная/планируемая ссылка).
|
// FileLink — строка таблицы file_link (одна созданная/планируемая ссылка).
|
||||||
type FileLink struct {
|
type FileLink struct {
|
||||||
ID int64 `db:"id"`
|
ID string `db:"id"`
|
||||||
DownloadID int64 `db:"download_id"`
|
DownloadID string `db:"download_id"`
|
||||||
ApplyBatchID string `db:"apply_batch_id"`
|
ApplyBatchID string `db:"apply_batch_id"`
|
||||||
SrcPath string `db:"src_path"`
|
SrcPath string `db:"src_path"`
|
||||||
DstPath string `db:"dst_path"`
|
DstPath string `db:"dst_path"`
|
||||||
Kind string `db:"kind"`
|
Kind string `db:"kind"`
|
||||||
Status string `db:"status"`
|
Status string `db:"status"`
|
||||||
|
Size int64 `db:"size"` // размер файла (байт), зафиксирован при линковке
|
||||||
CreatedAt string `db:"created_at"`
|
CreatedAt string `db:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,12 +182,13 @@ func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error {
|
|||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
now := FormatTime(Now())
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO file_link (download_id, apply_batch_id, src_path, dst_path, kind, status)
|
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
for _, l := range links {
|
for _, l := range links {
|
||||||
if _, err := tx.ExecContext(ctx, q,
|
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)
|
return fmt.Errorf("insert file_link: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +204,7 @@ VALUES (?, ?, ?, ?, ?, ?)`
|
|||||||
// раскладка забирает владение освободившимся путём, прежние записи перестают
|
// раскладка забирает владение освободившимся путём, прежние записи перестают
|
||||||
// считаться целью при сверке. Затрагивает только активные статусы раскладки
|
// считаться целью при сверке. Затрагивает только активные статусы раскладки
|
||||||
// (linked/copied/exists) и не трогает саму загрузку (download_id != ?).
|
// (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 {
|
if len(dstPaths) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -226,7 +228,7 @@ WHERE download_id != ?
|
|||||||
|
|
||||||
// LatestBatchID возвращает apply_batch_id последнего применённого батча
|
// LatestBatchID возвращает apply_batch_id последнего применённого батча
|
||||||
// загрузки (для undo) либо пустую строку, если ссылок нет.
|
// загрузки (для 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
|
var batch string
|
||||||
err := s.DB.GetContext(ctx, &batch,
|
err := s.DB.GetContext(ctx, &batch,
|
||||||
`SELECT apply_batch_id FROM file_link WHERE download_id = ?
|
`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
|
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 на ФС).
|
// DeleteFileLinksByBatch удаляет записи ссылок батча (после undo на ФС).
|
||||||
func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) error {
|
func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) error {
|
||||||
if _, err := s.DB.ExecContext(ctx,
|
if _, err := s.DB.ExecContext(ctx,
|
||||||
@@ -265,8 +308,8 @@ func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) erro
|
|||||||
// хранят значения для тега Jellyfin (напр. TVMaze отдаёт внешний TVDB-id —
|
// хранят значения для тега Jellyfin (напр. TVMaze отдаёт внешний TVDB-id —
|
||||||
// см. recognize), а не обязательно нативный id провайдера поиска.
|
// см. recognize), а не обязательно нативный id провайдера поиска.
|
||||||
type MetadataCandidate struct {
|
type MetadataCandidate struct {
|
||||||
ID int64 `db:"id"`
|
ID string `db:"id"`
|
||||||
RecognitionID int64 `db:"recognition_id"`
|
RecognitionID string `db:"recognition_id"`
|
||||||
Provider string `db:"provider"`
|
Provider string `db:"provider"`
|
||||||
ProviderID string `db:"provider_id"`
|
ProviderID string `db:"provider_id"`
|
||||||
Title sql.NullString `db:"title"`
|
Title sql.NullString `db:"title"`
|
||||||
@@ -287,12 +330,13 @@ func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate)
|
|||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
now := FormatTime(Now())
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO metadata_candidate (recognition_id, provider, provider_id, title, year, url)
|
INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
for _, c := range cands {
|
for _, c := range cands {
|
||||||
if _, err := tx.ExecContext(ctx, q,
|
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)
|
return fmt.Errorf("insert candidate: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,7 +347,7 @@ VALUES (?, ?, ?, ?, ?, ?)`
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListCandidatesByRecognition возвращает кандидатов попытки распознавания.
|
// 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
|
var out []MetadataCandidate
|
||||||
if err := s.DB.SelectContext(ctx, &out,
|
if err := s.DB.SelectContext(ctx, &out,
|
||||||
`SELECT * FROM metadata_candidate WHERE recognition_id = ? ORDER BY id`, recognitionID); err != nil {
|
`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).
|
// 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
|
var c MetadataCandidate
|
||||||
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
|
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if err != 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
|
return &c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCandidateChosen помечает кандидата выбранным, снимая отметку с прочих в
|
// 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)
|
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin tx: %w", err)
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
|||||||
@@ -6,14 +6,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func seedDownload(t *testing.T, st *Store) int64 {
|
func seedDownload(t *testing.T, st *Store) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
id, err := st.CreateDownload(context.Background(),
|
return mustCreate(t, st, "aabbccddeeff00112233445566778899aabbccdd")
|
||||||
newDownloading("aabbccddeeff00112233445566778899aabbccdd"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seed download: %v", err)
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateRecognition_AttemptsAndCurrent(t *testing.T) {
|
func TestCreateRecognition_AttemptsAndCurrent(t *testing.T) {
|
||||||
@@ -49,7 +44,7 @@ func TestCreateRecognition_AttemptsAndCurrent(t *testing.T) {
|
|||||||
t.Fatalf("get current: %v", err)
|
t.Fatalf("get current: %v", err)
|
||||||
}
|
}
|
||||||
if cur.ID != id2 {
|
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 {
|
if cur.AttemptNo != 2 {
|
||||||
t.Errorf("attempt_no = %d, want 2", cur.AttemptNo)
|
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) {
|
func TestSupersedeForeignLinks(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
owner := seedDownload(t, st)
|
owner := seedDownload(t, st)
|
||||||
foreign, err := st.CreateDownload(ctx,
|
foreign := mustCreate(t, st, "bbccddeeff00112233445566778899aabbccddee")
|
||||||
newDownloading("bbccddeeff00112233445566778899aabbccddee"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seed foreign: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shared := "/m/Movie (2024).mkv"
|
shared := "/m/Movie (2024).mkv"
|
||||||
// foreign разложена по shared (linked) и по своему пути (exists);
|
// foreign разложена по shared (linked) и по своему пути (exists);
|
||||||
@@ -244,7 +271,7 @@ func TestCandidates_Lifecycle(t *testing.T) {
|
|||||||
for _, c := range got {
|
for _, c := range got {
|
||||||
want := c.ID == chosenID
|
want := c.ID == chosenID
|
||||||
if c.Chosen != want {
|
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) {
|
func TestGetCandidate_None(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
c, err := st.GetCandidate(context.Background(), 999)
|
c, err := st.GetCandidate(context.Background(), "01hzzzzzzzzzzzzzzzzzzzzzzz")
|
||||||
if err != nil || c != nil {
|
if err != nil || c != nil {
|
||||||
t.Errorf("want nil,nil; got %+v, %v", c, err)
|
t.Errorf("want nil,nil; got %+v, %v", c, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import (
|
|||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
_ "modernc.org/sqlite" // драйвер database/sql, имя "sqlite"
|
_ "modernc.org/sqlite" // драйвер database/sql, имя "sqlite"
|
||||||
|
|
||||||
|
// Go-миграции goose (регистрируются в init(); SQL-миграции — embed ниже).
|
||||||
|
_ "git.vakhrushev.me/av/jellybit/internal/store/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migrations/*.sql
|
//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(
|
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,
|
dbPath,
|
||||||
)
|
)
|
||||||
db, err := sqlx.Connect("sqlite", dsn)
|
db, err := sqlx.Connect("sqlite", dsn)
|
||||||
|
|||||||
+31
-28
@@ -4,12 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
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/ingest"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
)
|
)
|
||||||
@@ -30,13 +30,13 @@ type Ingestor interface {
|
|||||||
|
|
||||||
// Reviewer — операции ревью (worker.Worker).
|
// Reviewer — операции ревью (worker.Worker).
|
||||||
type Reviewer interface {
|
type Reviewer interface {
|
||||||
ReviewData(ctx context.Context, id int64) (*worker.ReviewData, error)
|
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
|
||||||
Apply(ctx context.Context, id int64) error
|
Apply(ctx context.Context, id string) error
|
||||||
Refine(ctx context.Context, id int64, hint string) error
|
Refine(ctx context.Context, id string, hint string) error
|
||||||
SetType(ctx context.Context, id int64, mediaType string) error
|
SetType(ctx context.Context, id string, mediaType string) error
|
||||||
Defer(ctx context.Context, id int64) error
|
Defer(ctx context.Context, id string) error
|
||||||
Cancel(ctx context.Context, id int64) error
|
Cancel(ctx context.Context, id string) error
|
||||||
Retry(ctx context.Context, id int64) error
|
Retry(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config — параметры бота.
|
// Config — параметры бота.
|
||||||
@@ -54,8 +54,8 @@ type Bot struct {
|
|||||||
webBase string
|
webBase string
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
|
||||||
mu sync.Mutex // защищает pending
|
mu sync.Mutex // защищает pending
|
||||||
pending map[int64]int64 // chatID → downloadID, ждущий подсказку
|
pending map[int64]string // chatID → downloadID, ждущий подсказку
|
||||||
}
|
}
|
||||||
|
|
||||||
// New собирает бота поверх клиента Telegram.
|
// New собирает бота поверх клиента Telegram.
|
||||||
@@ -71,7 +71,7 @@ func New(client teleAPI, ing Ingestor, rev Reviewer, cfg Config, log *slog.Logge
|
|||||||
allowed: allowed,
|
allowed: allowed,
|
||||||
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
|
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
|
||||||
log: log,
|
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)
|
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+strconv.FormatInt(id, 10)+"…", nil)
|
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+id+"…", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,9 +151,9 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
|||||||
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := fmt.Sprintf("Принято #%d — %s.", res.DownloadID, res.State)
|
msg := fmt.Sprintf("Принято #%s — %s.", res.DownloadID, res.State)
|
||||||
if res.Deduplicated {
|
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)
|
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)
|
action, id, val := parseCallback(cq.Data)
|
||||||
if id == 0 {
|
if id == "" {
|
||||||
b.answer(cq.ID, "")
|
// Пустой/невалидный id — в т.ч. старые числовые кнопки, оставшиеся в
|
||||||
|
// истории чата до перехода на ULID: отвечаем понятно, а не молчим.
|
||||||
|
b.answer(cq.ID, "Кнопка устарела — откройте задачу в вебе")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
chatID := cq.Message.Chat.ID
|
chatID := cq.Message.Chat.ID
|
||||||
@@ -201,7 +203,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
|||||||
case "refine":
|
case "refine":
|
||||||
b.setPending(chatID, id)
|
b.setPending(chatID, id)
|
||||||
b.answer(cq.ID, "Жду подсказку")
|
b.answer(cq.ID, "Жду подсказку")
|
||||||
b.send(chatID, "Ответьте сообщением с подсказкой для #"+strconv.FormatInt(id, 10)+".", nil)
|
b.send(chatID, "Ответьте сообщением с подсказкой для #"+id+".", nil)
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
b.answer(cq.ID, "")
|
b.answer(cq.ID, "")
|
||||||
@@ -218,7 +220,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// refreshCard перечитывает задачу и обновляет карточку на месте.
|
// 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)
|
rd, err := b.reviewer.ReviewData(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.log.Warn("telegram refresh card failed", "download_id", id, "error", err)
|
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) ---
|
// --- Notifier (worker.Notifier) ---
|
||||||
|
|
||||||
// Notify шлёт карточку подтверждения/готовности всем доверенным пользователям.
|
// 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)
|
rd, err := b.reviewer.ReviewData(ctx, downloadID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.log.Warn("telegram notify review data", "download_id", downloadID, "error", err)
|
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.mu.Lock()
|
||||||
b.pending[chatID] = id
|
b.pending[chatID] = id
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) takePending(chatID int64) (int64, bool) {
|
func (b *Bot) takePending(chatID int64) (string, bool) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
id, ok := b.pending[chatID]
|
id, ok := b.pending[chatID]
|
||||||
@@ -300,20 +302,21 @@ func (b *Bot) takePending(chatID int64) (int64, bool) {
|
|||||||
// opErr — сообщение публичного канала Telegram по доменной ошибке: нейтральный
|
// opErr — сообщение публичного канала Telegram по доменной ошибке: нейтральный
|
||||||
// текст + download_id для корреляции с логами (полная ошибка уже там, на
|
// текст + download_id для корреляции с логами (полная ошибка уже там, на
|
||||||
// доменной границе). Сырой err.Error() пользователю не показываем. Если id
|
// доменной границе). Сырой err.Error() пользователю не показываем. Если id
|
||||||
// операции ещё нет (downloadID == 0) — дружелюбный текст без ключа.
|
// операции ещё нет (downloadID == "") — дружелюбный текст без ключа.
|
||||||
func opErr(msg string, downloadID int64) string {
|
func opErr(msg string, downloadID string) string {
|
||||||
if downloadID > 0 {
|
if downloadID != "" {
|
||||||
return fmt.Sprintf("%s (download_id=%d).", msg, downloadID)
|
return fmt.Sprintf("%s (download_id=%s).", msg, downloadID)
|
||||||
}
|
}
|
||||||
return msg + "."
|
return msg + "."
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCallback разбирает "action[:id[:value]]".
|
// parseCallback разбирает "action[:id[:value]]". id валидируется как ULID
|
||||||
func parseCallback(data string) (action string, id int64, value string) {
|
// (входная граница); невалидный/устаревший (числовой) → пустая строка.
|
||||||
|
func parseCallback(data string) (action string, id string, value string) {
|
||||||
parts := strings.Split(data, ":")
|
parts := strings.Split(data, ":")
|
||||||
action = parts[0]
|
action = parts[0]
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
id, _ = strconv.ParseInt(parts[1], 10, 64)
|
id, _ = ident.Parse(parts[1])
|
||||||
}
|
}
|
||||||
if len(parts) > 2 {
|
if len(parts) > 2 {
|
||||||
value = parts[2]
|
value = parts[2]
|
||||||
|
|||||||
+66
-42
@@ -60,52 +60,55 @@ func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Res
|
|||||||
|
|
||||||
type fakeReviewer struct {
|
type fakeReviewer struct {
|
||||||
data *worker.ReviewData
|
data *worker.ReviewData
|
||||||
applied []int64
|
applied []string
|
||||||
refined map[int64]string
|
refined map[string]string
|
||||||
typed map[int64]string
|
typed map[string]string
|
||||||
deferred []int64
|
deferred []string
|
||||||
canceled []int64
|
canceled []string
|
||||||
retried []int64
|
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
|
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)
|
f.applied = append(f.applied, id)
|
||||||
return nil
|
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 {
|
if f.refined == nil {
|
||||||
f.refined = map[int64]string{}
|
f.refined = map[string]string{}
|
||||||
}
|
}
|
||||||
f.refined[id] = hint
|
f.refined[id] = hint
|
||||||
return nil
|
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 {
|
if f.typed == nil {
|
||||||
f.typed = map[int64]string{}
|
f.typed = map[string]string{}
|
||||||
}
|
}
|
||||||
f.typed[id] = t
|
f.typed[id] = t
|
||||||
return nil
|
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)
|
f.deferred = append(f.deferred, id)
|
||||||
return nil
|
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)
|
f.canceled = append(f.canceled, id)
|
||||||
return nil
|
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)
|
f.retried = append(f.retried, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
|
||||||
|
const tid = "01arz3ndektsv4rrffq69g5fav"
|
||||||
|
|
||||||
func reviewData(state store.State) *worker.ReviewData {
|
func reviewData(state store.State) *worker.ReviewData {
|
||||||
s, e := 2, 1
|
s, e := 2, 1
|
||||||
return &worker.ReviewData{
|
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{
|
Recognition: &store.Recognition{
|
||||||
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
|
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
|
||||||
Reasons: `["неполный пак"]`,
|
Reasons: `["неполный пак"]`,
|
||||||
@@ -123,7 +126,7 @@ func reviewData(state store.State) *worker.ReviewData {
|
|||||||
func newTestBot(t *testing.T, allowed []int64) (*Bot, *fakeAPI, *fakeIngestor, *fakeReviewer) {
|
func newTestBot(t *testing.T, allowed []int64) (*Bot, *fakeAPI, *fakeIngestor, *fakeReviewer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
api := &fakeAPI{}
|
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)}
|
rev := &fakeReviewer{data: reviewData(store.StateReview)}
|
||||||
b := New(api, ing, rev, Config{AllowedUserIDs: allowed, WebBaseURL: "http://host:8080"},
|
b := New(api, ing, rev, Config{AllowedUserIDs: allowed, WebBaseURL: "http://host:8080"},
|
||||||
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
@@ -146,7 +149,7 @@ func TestBot_IngestFromMagnet(t *testing.T) {
|
|||||||
if ing.lastReq.Context != "крутой сериал" {
|
if ing.lastReq.Context != "крутой сериал" {
|
||||||
t.Errorf("context = %q", 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)
|
t.Errorf("sent = %+v", api.sent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,10 +177,10 @@ func TestBot_NoMagnet(t *testing.T) {
|
|||||||
func TestBot_RefineViaReply(t *testing.T) {
|
func TestBot_RefineViaReply(t *testing.T) {
|
||||||
b, _, _, rev := newTestBot(t, []int64{7})
|
b, _, _, rev := newTestBot(t, []int64{7})
|
||||||
// Кнопка «Уточнить» поставила ожидание подсказки для чата 7.
|
// Кнопка «Уточнить» поставила ожидание подсказки для чата 7.
|
||||||
b.setPending(7, 5)
|
b.setPending(7, tid)
|
||||||
b.handleMessage(context.Background(), msgFrom(7, "это второй сезон"))
|
b.handleMessage(context.Background(), msgFrom(7, "это второй сезон"))
|
||||||
|
|
||||||
if rev.refined[5] != "это второй сезон" {
|
if rev.refined[tid] != "это второй сезон" {
|
||||||
t.Errorf("refine = %v", rev.refined)
|
t.Errorf("refine = %v", rev.refined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,9 +194,9 @@ func cbFrom(userID int64, data string) *tgbotapi.CallbackQuery {
|
|||||||
|
|
||||||
func TestBot_CallbackApply(t *testing.T) {
|
func TestBot_CallbackApply(t *testing.T) {
|
||||||
b, api, _, rev := newTestBot(t, []int64{7})
|
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)
|
t.Errorf("applied = %v", rev.applied)
|
||||||
}
|
}
|
||||||
if len(api.answers) != 1 {
|
if len(api.answers) != 1 {
|
||||||
@@ -206,18 +209,18 @@ func TestBot_CallbackApply(t *testing.T) {
|
|||||||
|
|
||||||
func TestBot_CallbackType(t *testing.T) {
|
func TestBot_CallbackType(t *testing.T) {
|
||||||
b, _, _, rev := newTestBot(t, []int64{7})
|
b, _, _, rev := newTestBot(t, []int64{7})
|
||||||
b.handleCallback(context.Background(), cbFrom(7, "type:5:movie"))
|
b.handleCallback(context.Background(), cbFrom(7, "type:"+tid+":movie"))
|
||||||
if rev.typed[5] != "movie" {
|
if rev.typed[tid] != "movie" {
|
||||||
t.Errorf("typed = %v", rev.typed)
|
t.Errorf("typed = %v", rev.typed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBot_CallbackRefineSetsPending(t *testing.T) {
|
func TestBot_CallbackRefineSetsPending(t *testing.T) {
|
||||||
b, api, _, _ := newTestBot(t, []int64{7})
|
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 {
|
if id, ok := b.takePending(7); !ok || id != tid {
|
||||||
t.Errorf("pending = %d,%v", id, ok)
|
t.Errorf("pending = %s,%v", id, ok)
|
||||||
}
|
}
|
||||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "подсказкой") {
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "подсказкой") {
|
||||||
t.Errorf("sent = %+v", api.sent)
|
t.Errorf("sent = %+v", api.sent)
|
||||||
@@ -226,7 +229,7 @@ func TestBot_CallbackRefineSetsPending(t *testing.T) {
|
|||||||
|
|
||||||
func TestBot_CallbackDeniesUnknown(t *testing.T) {
|
func TestBot_CallbackDeniesUnknown(t *testing.T) {
|
||||||
b, _, _, rev := newTestBot(t, []int64{7})
|
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 {
|
if len(rev.applied) != 0 {
|
||||||
t.Error("чужой колбэк не должен исполняться")
|
t.Error("чужой колбэк не должен исполняться")
|
||||||
}
|
}
|
||||||
@@ -234,12 +237,12 @@ func TestBot_CallbackDeniesUnknown(t *testing.T) {
|
|||||||
|
|
||||||
func TestBot_NotifyReview(t *testing.T) {
|
func TestBot_NotifyReview(t *testing.T) {
|
||||||
b, api, _, _ := newTestBot(t, []int64{7, 8})
|
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 { // обоим доверенным
|
if len(api.sent) != 2 { // обоим доверенным
|
||||||
t.Fatalf("sent to %d chats, want 2", len(api.sent))
|
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)
|
t.Errorf("card text = %q", api.sent[0].text)
|
||||||
}
|
}
|
||||||
if !api.sent[0].hasKB {
|
if !api.sent[0].hasKB {
|
||||||
@@ -250,9 +253,11 @@ func TestBot_NotifyReview(t *testing.T) {
|
|||||||
func TestBot_NotifyDone(t *testing.T) {
|
func TestBot_NotifyDone(t *testing.T) {
|
||||||
b, api, _, rev := newTestBot(t, []int64{7})
|
b, api, _, rev := newTestBot(t, []int64{7})
|
||||||
rev.data = reviewData(store.StateDone)
|
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)
|
t.Errorf("sent = %+v", api.sent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,9 +265,12 @@ func TestBot_NotifyDone(t *testing.T) {
|
|||||||
func TestBot_NotifyFailed(t *testing.T) {
|
func TestBot_NotifyFailed(t *testing.T) {
|
||||||
b, api, _, rev := newTestBot(t, []int64{7})
|
b, api, _, rev := newTestBot(t, []int64{7})
|
||||||
rev.data = reviewData(store.StateFailed)
|
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)
|
t.Errorf("sent = %+v", api.sent)
|
||||||
}
|
}
|
||||||
if !api.sent[0].hasKB { // кнопка повтора
|
if !api.sent[0].hasKB { // кнопка повтора
|
||||||
@@ -273,20 +281,36 @@ func TestBot_NotifyFailed(t *testing.T) {
|
|||||||
func TestBot_CallbackRetry(t *testing.T) {
|
func TestBot_CallbackRetry(t *testing.T) {
|
||||||
b, _, _, rev := newTestBot(t, []int64{7})
|
b, _, _, rev := newTestBot(t, []int64{7})
|
||||||
rev.data = reviewData(store.StateFailed)
|
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)
|
t.Errorf("retried = %v", rev.retried)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseCallback(t *testing.T) {
|
func TestParseCallback(t *testing.T) {
|
||||||
a, id, v := parseCallback("type:5:series")
|
a, id, v := parseCallback("type:" + tid + ":series")
|
||||||
if a != "type" || id != 5 || v != "series" {
|
if a != "type" || id != tid || v != "series" {
|
||||||
t.Errorf("got %q %d %q", a, id, v)
|
t.Errorf("got %q %q %q", a, id, v)
|
||||||
}
|
}
|
||||||
a, id, v = parseCallback("apply:9")
|
a, id, v = parseCallback("apply:" + tid)
|
||||||
if a != "apply" || id != 9 || v != "" {
|
if a != "apply" || id != tid || v != "" {
|
||||||
t.Errorf("got %q %d %q", a, id, 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
@@ -3,7 +3,6 @@ package tgbot
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
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:
|
case store.StateReview, store.StateDeferred:
|
||||||
return b.reviewCard(rd)
|
return b.reviewCard(rd)
|
||||||
case store.StateRecognizing:
|
case store.StateRecognizing:
|
||||||
return "⏳ Распознаю #" + itoa(id) + "…", b.webOnly(id)
|
return "⏳ Распознаю #" + id + "…", b.webOnly(id)
|
||||||
case store.StateLinking:
|
case store.StateLinking:
|
||||||
return "⏳ Раскладываю #" + itoa(id) + "…", nil
|
return "⏳ Раскладываю #" + id + "…", nil
|
||||||
case store.StateDone:
|
case store.StateDone:
|
||||||
return b.renderDone(rd), b.webOnly(id)
|
return b.renderDone(rd), b.webOnly(id)
|
||||||
default:
|
default:
|
||||||
text := fmt.Sprintf("Задача #%d — %s.", id, state)
|
text := fmt.Sprintf("Задача #%s — %s.", id, state)
|
||||||
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
||||||
text += "\n" + msg
|
text += "\n" + msg
|
||||||
}
|
}
|
||||||
@@ -43,7 +42,7 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
|||||||
id := rd.Download.ID
|
id := rd.Download.ID
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%d\n", id)
|
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%s\n", id)
|
||||||
if src := contextOrSource(rd); src != "" {
|
if src := contextOrSource(rd); src != "" {
|
||||||
fmt.Fprintf(&sb, "Источник: %s\n", shorten(src, 80))
|
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 {
|
func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
|
||||||
id := rd.Download.ID
|
id := rd.Download.ID
|
||||||
sid := itoa(id)
|
sid := id
|
||||||
|
|
||||||
var row1 []tgbotapi.InlineKeyboardButton
|
var row1 []tgbotapi.InlineKeyboardButton
|
||||||
if len(rd.Preview) > 0 {
|
if len(rd.Preview) > 0 {
|
||||||
@@ -86,11 +85,21 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
|
|||||||
return &kb
|
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 — короткое сообщение о готовности.
|
// renderDone — короткое сообщение о готовности.
|
||||||
func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
||||||
title := rd.Plan.Title
|
title := displayTitle(rd)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = "#" + itoa(rd.Download.ID)
|
title = "#" + rd.Download.ID
|
||||||
}
|
}
|
||||||
n := len(rd.Preview)
|
n := len(rd.Preview)
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
@@ -101,9 +110,9 @@ func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
|||||||
|
|
||||||
// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
|
// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
|
||||||
func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string {
|
func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string {
|
||||||
title := rd.Plan.Title
|
title := displayTitle(rd)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = "#" + itoa(rd.Download.ID)
|
title = "#" + rd.Download.ID
|
||||||
}
|
}
|
||||||
switch event {
|
switch event {
|
||||||
case worker.EventTargetMissing:
|
case worker.EventTargetMissing:
|
||||||
@@ -123,7 +132,12 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
|
|||||||
if rd.Download.State == store.StateStuck {
|
if rd.Download.State == store.StateStuck {
|
||||||
verb = "зависла"
|
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 != "" {
|
if code := rd.Download.ErrorCode.String; code != "" {
|
||||||
fmt.Fprintf(&sb, " (%s)", code)
|
fmt.Fprintf(&sb, " (%s)", code)
|
||||||
}
|
}
|
||||||
@@ -139,9 +153,9 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// retryKeyboard — клавиатура для failed/stuck: повтор + опц. ссылка в веб.
|
// retryKeyboard — клавиатура для failed/stuck: повтор + опц. ссылка в веб.
|
||||||
func (b *Bot) retryKeyboard(id int64) *tgbotapi.InlineKeyboardMarkup {
|
func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||||
row := []tgbotapi.InlineKeyboardButton{
|
row := []tgbotapi.InlineKeyboardButton{
|
||||||
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+itoa(id)),
|
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+id),
|
||||||
}
|
}
|
||||||
if url := b.reviewURL(id); url != "" {
|
if url := b.reviewURL(id); url != "" {
|
||||||
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
|
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
|
||||||
@@ -150,7 +164,7 @@ func (b *Bot) retryKeyboard(id int64) *tgbotapi.InlineKeyboardMarkup {
|
|||||||
return &kb
|
return &kb
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
|
func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||||
url := b.reviewURL(id)
|
url := b.reviewURL(id)
|
||||||
if url == "" {
|
if url == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -161,11 +175,11 @@ func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
|
|||||||
return &kb
|
return &kb
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) reviewURL(id int64) string {
|
func (b *Bot) reviewURL(id string) string {
|
||||||
if b.webBase == "" {
|
if b.webBase == "" {
|
||||||
return ""
|
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]) + "…"
|
return string(r[:n]) + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|
|
||||||
|
|||||||
+49
-31
@@ -2,6 +2,7 @@ package worker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -10,13 +11,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// discover усыновляет новые раздачи: для каждого торрента с нашей категорией
|
// discover усыновляет новые раздачи: для каждого торрента с нашей категорией
|
||||||
// ИЛИ тегом, чьего infohash ещё нет в БД, заводит задачу downloading. Дальше
|
// ИЛИ тегом, чьих хешей ещё нет в БД, заводит задачу downloading. Дальше
|
||||||
// её ведёт обычный reconcile. Вызывается под w.mu.
|
// её ведёт обычный reconcile. Вызывается под w.mu.
|
||||||
//
|
//
|
||||||
// Корректность при гонке с Ingest (другая горутина): Ingest пишет строку в
|
// Корректность при гонке с Ingest (другая горутина): и adopt, и приём идут
|
||||||
// БД до добавления в qBit и ставит idempotency_key=infohash, на который есть
|
// через store.CreateDownloadIfNoActive — атомарный check-then-insert в одной
|
||||||
// UNIQUE-индекс. Поэтому даже если тик и Ingest столкнутся в окне «проверил →
|
// write-транзакции; при столкновении второй участник получает существующую
|
||||||
// вставляю», второй INSERT упадёт на индексе, и adopt просто пропустит.
|
// активную задачу и просто пропускает.
|
||||||
func (w *Worker) discover(ctx context.Context, torrents []qbt.Torrent) {
|
func (w *Worker) discover(ctx context.Context, torrents []qbt.Torrent) {
|
||||||
for _, t := range torrents {
|
for _, t := range torrents {
|
||||||
if w.tracked(t) {
|
if w.tracked(t) {
|
||||||
@@ -35,13 +36,13 @@ func (w *Worker) tracked(t qbt.Torrent) bool {
|
|||||||
|
|
||||||
// adopt заводит задачу под торрент, если его ещё не видели.
|
// adopt заводит задачу под торрент, если его ещё не видели.
|
||||||
func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
||||||
infohash := firstInfohash(t)
|
hashes := torrentHashes(t)
|
||||||
if infohash == "" {
|
if len(hashes) == 0 {
|
||||||
return // нечем идентифицировать (напр. ещё metaDL без хэша)
|
return // нечем идентифицировать (напр. ещё metaDL без хэша)
|
||||||
}
|
}
|
||||||
exists, err := w.store.ExistsByInfohash(ctx, infohash)
|
exists, err := w.store.ExistsByInfohash(ctx, hashes...)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if exists {
|
if exists {
|
||||||
@@ -49,33 +50,29 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
d := &store.Download{
|
d := &store.Download{
|
||||||
SourceType: store.SourceMagnet,
|
SourceType: store.SourceMagnet,
|
||||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
SourceRef: magnetURN(hashes[0]),
|
||||||
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
||||||
Infohash: store.NullString(infohash),
|
State: store.StateDownloading,
|
||||||
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 {
|
if err != nil {
|
||||||
// Гонка: Ingest/другой тик мог вставить запись между проверкой и
|
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", hashes[0], "error", err)
|
||||||
// вставкой — UNIQUE-индекс это отсёк. Если запись появилась, всё ок.
|
|
||||||
if ex, _ := w.store.ExistsByInfohash(ctx, infohash); ex {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", infohash, "error", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return // гонка с Ingest/другим тиком: задача уже заведена — всё ок
|
||||||
|
}
|
||||||
// Базис сортировки — время добавления в источник; у усыновлённого оно уже
|
// Базис сортировки — время добавления в источник; у усыновлённого оно уже
|
||||||
// известно (created_at задачи было бы моментом усыновления, не добавления).
|
// известно (created_at задачи было бы моментом усыновления, не добавления).
|
||||||
if t.AddedOn > 0 {
|
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",
|
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",
|
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)
|
"category", t.Category, "tags", t.Tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,12 +89,33 @@ func hasTag(tags, tag string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// firstInfohash возвращает первый непустой infohash торрента (нижний регистр).
|
// torrentHashes — все непустые хеши торрента (нижний регистр, без дублей,
|
||||||
func firstInfohash(t qbt.Torrent) string {
|
// v1-приоритетный порядок: v1 раньше v2). Единственный сборщик хешей
|
||||||
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
// торрента для записи в БД: t.Hash берётся только когда qBittorrent не
|
||||||
if h != "" {
|
// отдал infohash_v1/v2 (старые версии API); у v2-only раздачи t.Hash — это
|
||||||
return strings.ToLower(h)
|
// УСЕЧЁННЫЙ до 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import (
|
|||||||
const ihDisc = "7931aa3ed6666746012f5739d099b5bc64d72a16"
|
const ihDisc = "7931aa3ed6666746012f5739d099b5bc64d72a16"
|
||||||
|
|
||||||
func emptyStore() *fakeStore {
|
func emptyStore() *fakeStore {
|
||||||
return &fakeStore{downloads: map[int64]*store.Download{}}
|
return &fakeStore{downloads: map[string]*store.Download{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// findByInfohash возвращает усыновлённую задачу по infohash.
|
// findByInfohash возвращает усыновлённую задачу по infohash.
|
||||||
func findByInfohash(st *fakeStore, infohash string) *store.Download {
|
func findByInfohash(st *fakeStore, infohash string) *store.Download {
|
||||||
for _, d := range st.downloads {
|
for _, d := range st.downloads {
|
||||||
if d.Infohash.String == infohash {
|
if hasAnyHash(d, []string{infohash}) {
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,8 +38,8 @@ func TestDiscover_AdoptsByCategory(t *testing.T) {
|
|||||||
if d.State != store.StateDownloading || d.SourceType != store.SourceMagnet {
|
if d.State != store.StateDownloading || d.SourceType != store.SourceMagnet {
|
||||||
t.Errorf("adopted = %+v", d)
|
t.Errorf("adopted = %+v", d)
|
||||||
}
|
}
|
||||||
if d.IdempotencyKey.String != ihDisc {
|
if len(d.Infohashes) != 1 || d.Infohashes[0].Kind != store.HashV1 {
|
||||||
t.Errorf("idempotency_key = %q", d.IdempotencyKey.String)
|
t.Errorf("infohashes = %+v", d.Infohashes)
|
||||||
}
|
}
|
||||||
// Усыновление берёт заголовок из имени торрента qBittorrent и фиксирует
|
// Усыновление берёт заголовок из имени торрента qBittorrent и фиксирует
|
||||||
// время добавления (added_on) как базис сортировки.
|
// время добавления (added_on) как базис сортировки.
|
||||||
@@ -79,8 +79,8 @@ func TestDiscover_SkipsUntracked(t *testing.T) {
|
|||||||
func TestDiscover_SkipsExisting(t *testing.T) {
|
func TestDiscover_SkipsExisting(t *testing.T) {
|
||||||
st := emptyStore()
|
st := emptyStore()
|
||||||
// Уже есть задача (напр. терминальная done) — не переусыновляем.
|
// Уже есть задача (напр. терминальная done) — не переусыновляем.
|
||||||
st.downloads[1] = &store.Download{
|
st.downloads["1"] = &store.Download{
|
||||||
ID: 1, State: store.StateDone, Infohash: store.NullString(ihDisc),
|
ID: "1", State: store.StateDone, Infohashes: hashesOf("1", ihDisc),
|
||||||
}
|
}
|
||||||
w := newTestWorker(st, &fakeQbt{})
|
w := newTestWorker(st, &fakeQbt{})
|
||||||
w.discover(context.Background(), []qbt.Torrent{
|
w.discover(context.Background(), []qbt.Torrent{
|
||||||
@@ -104,9 +104,9 @@ func TestDiscover_SkipsNoInfohash(t *testing.T) {
|
|||||||
// уже скачанная раздача за один тик усыновляется и доходит до completed.
|
// уже скачанная раздача за один тик усыновляется и доходит до completed.
|
||||||
func TestPoll_CapturesSourceAddedAt(t *testing.T) {
|
func TestPoll_CapturesSourceAddedAt(t *testing.T) {
|
||||||
ih := "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
|
ih := "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
|
||||||
st := &fakeStore{downloads: map[int64]*store.Download{
|
st := &fakeStore{downloads: map[string]*store.Download{
|
||||||
1: {ID: 1, State: store.StateDownloading, SourceType: store.SourceMagnet,
|
"1": {ID: "1", State: store.StateDownloading, SourceType: store.SourceMagnet,
|
||||||
Infohash: store.NullString(ih), IdempotencyKey: store.NullString(ih)},
|
Infohashes: hashesOf("1", ih)},
|
||||||
}}
|
}}
|
||||||
qb := &fakeQbt{torrents: []qbt.Torrent{
|
qb := &fakeQbt{torrents: []qbt.Torrent{
|
||||||
{Hash: ih, Name: "X", Category: "jellybit", State: "downloading", AddedOn: 1_700_000_000},
|
{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 {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
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 не захвачен при поллинге активной задачи")
|
t.Fatalf("source_added_at не захвачен при поллинге активной задачи")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,14 +160,60 @@ func TestHasTag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFirstInfohash(t *testing.T) {
|
func TestTorrentHashes(t *testing.T) {
|
||||||
if got := firstInfohash(qbt.Torrent{Hash: "ABC"}); got != "abc" {
|
got := torrentHashes(qbt.Torrent{Hash: "ABC", InfohashV1: "abc", InfohashV2: "DEF"})
|
||||||
t.Errorf("got %q", got)
|
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" {
|
if got := torrentHashes(qbt.Torrent{}); len(got) != 0 {
|
||||||
t.Errorf("got %q", got)
|
t.Errorf("got %v, want empty", got)
|
||||||
}
|
}
|
||||||
if got := firstInfohash(qbt.Torrent{}); got != "" {
|
// Старый qBittorrent без infohash_v1/v2 — берём hash.
|
||||||
t.Errorf("got %q, want empty", got)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ import (
|
|||||||
func TestPollBuildsLiveSnapshot(t *testing.T) {
|
func TestPollBuildsLiveSnapshot(t *testing.T) {
|
||||||
qb := &fakeQbt{torrents: []qbt.Torrent{
|
qb := &fakeQbt{torrents: []qbt.Torrent{
|
||||||
// Качается (Category пуст → discover не усыновляет, store не мешает).
|
// Качается (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 (три ключа).
|
// Сидирует, торрент v2 (три ключа).
|
||||||
{
|
{
|
||||||
Hash: "bbb", InfohashV1: "bbb1", InfohashV2: "BBB2",
|
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,
|
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 {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
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 {
|
if dl.Progress != 0.5 || dl.DlSpeed != 1000 || dl.ETA != 120 {
|
||||||
t.Errorf("телеметрия качания неверна: %+v", dl)
|
t.Errorf("телеметрия качания неверна: %+v", dl)
|
||||||
}
|
}
|
||||||
|
if dl.TotalSize != 4096 {
|
||||||
|
t.Errorf("полный размер качающейся задачи: got %d, want 4096", dl.TotalSize)
|
||||||
|
}
|
||||||
|
|
||||||
// Сидирующая задача находится по любому из трёх хэшей (lowercase).
|
// Сидирующая задача находится по любому из трёх хэшей (lowercase).
|
||||||
for _, h := range []string{"bbb", "bbb1", "BBB2", "bbb2"} {
|
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 {
|
if sd.Ratio != 2.5 || sd.Seeds != 3 || sd.Peers != 1 || sd.Uploaded != 999 || sd.UpSpeed != 50 {
|
||||||
t.Errorf("ключ %q: статистика раздачи неверна: %+v", h, sd)
|
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 {
|
if _, ok := w.Live("unknownhash"); ok {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
@@ -57,11 +56,11 @@ func (w *Worker) reconcileDesync(ctx context.Context, byHash map[string]qbt.Torr
|
|||||||
// reconcileOneDesync сверяет одну задачу: вычисляет присутствие источника (с
|
// reconcileOneDesync сверяет одну задачу: вычисляет присутствие источника (с
|
||||||
// дебаунсом) и цели, выводит состояние и переходит при изменении.
|
// дебаунсом) и цели, выводит состояние и переходит при изменении.
|
||||||
func (w *Worker) reconcileOneDesync(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
|
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 // нечем сопоставить источник
|
return // нечем сопоставить источник
|
||||||
}
|
}
|
||||||
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
|
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
||||||
_, sourceSeen := byHash[strings.ToLower(d.Infohash.String)]
|
_, sourceSeen := torrentFor(d, byHash)
|
||||||
|
|
||||||
// Дебаунс пропажи источника: считаем удалённым только после порога подряд
|
// Дебаунс пропажи источника: считаем удалённым только после порога подряд
|
||||||
// идущих промахов; любое появление сбрасывает счётчик.
|
// идущих промахов; любое появление сбрасывает счётчик.
|
||||||
@@ -104,7 +103,7 @@ func (w *Worker) debounceSource(ctx context.Context, d store.Download, sourceSee
|
|||||||
// targetPresent сообщает, существуют ли разложенные хардлинки задачи. Цель
|
// targetPresent сообщает, существуют ли разложенные хардлинки задачи. Цель
|
||||||
// считается присутствующей, только если существуют ВСЕ ссылки последнего
|
// считается присутствующей, только если существуют ВСЕ ссылки последнего
|
||||||
// батча; частичная пропажа — это отсутствие цели (библиотека сломана → relink).
|
// батча; частичная пропажа — это отсутствие цели (библиотека сломана → 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)
|
batch, err := w.store.LatestBatchID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("latest batch: %w", err)
|
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 возвращает одну зависшую задачу в поток, если её торрент
|
// reconcileOneRecovery возвращает одну зависшую задачу в поток, если её торрент
|
||||||
// присутствует и продвинулся за условие падения.
|
// присутствует и продвинулся за условие падения.
|
||||||
func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
|
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
|
return
|
||||||
}
|
}
|
||||||
t, ok := byHash[strings.ToLower(d.Infohash.String)]
|
t, ok := torrentFor(d, byHash)
|
||||||
if !ok {
|
if !ok {
|
||||||
return // источника нет — оставляем как есть (вернёт ручной retry)
|
return // источника нет — оставляем как есть (вернёт ручной retry)
|
||||||
}
|
}
|
||||||
@@ -194,28 +193,22 @@ func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byH
|
|||||||
if want == "" {
|
if want == "" {
|
||||||
return // переходное состояние qBit (moving/checking) — ждём
|
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 снят при падении). Оба
|
// пока задача лежала в failed, тем же infohash могла завладеть другая
|
||||||
// целевых состояния (downloading/completed) нетерминальны → SetDownloadState
|
// активная задача (новый приём). Тогда старую оставляем в failed.
|
||||||
// восстановит 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
|
|
||||||
}
|
|
||||||
// error_code/error_msg не пишем — задача снова здорова; причину в лог, а не в
|
// error_code/error_msg не пишем — задача снова здорова; причину в лог, а не в
|
||||||
// поле ошибки (иначе она светилась бы в UI/REST как ошибка живой задачи).
|
// поле ошибки (иначе она светилась бы в UI/REST как ошибка живой задачи).
|
||||||
logctx.From(ctx).Info("recovery from failure", "to", want, "qbit_state", t.State)
|
if err := w.store.ActivateIfNoOtherActive(ctx, d.ID, want, "", ""); err != nil {
|
||||||
w.transition(ctx, d, want, "", "")
|
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 сообщает, продвинулся ли торрент за условие, по которому
|
// torrentProgressed сообщает, продвинулся ли торрент за условие, по которому
|
||||||
@@ -256,10 +249,10 @@ func recoveredState(state string) store.State {
|
|||||||
// qBittorrent прямо сейчас. При отсутствии приводит состояние к реальности и
|
// qBittorrent прямо сейчас. При отсутствии приводит состояние к реальности и
|
||||||
// возвращает ErrConflict. Недоступность qBittorrent — честный отказ операции.
|
// возвращает ErrConflict. Недоступность qBittorrent — честный отказ операции.
|
||||||
func (w *Worker) ensureSourcePresent(ctx context.Context, d *store.Download, op string) error {
|
func (w *Worker) ensureSourcePresent(ctx context.Context, d *store.Download, op string) error {
|
||||||
if !d.Infohash.Valid {
|
if len(d.Infohashes) == 0 {
|
||||||
return fmt.Errorf("%s: download %d has no infohash", op, d.ID)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("%s: %w", op, err)
|
return fmt.Errorf("%s: %w", op, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ func newReconcileFixture(t *testing.T, state store.State, sourcePresent, makeTar
|
|||||||
}
|
}
|
||||||
|
|
||||||
st := newMemStore()
|
st := newMemStore()
|
||||||
d := completedDownload(1)
|
d := completedDownload("1")
|
||||||
d.State = state
|
d.State = state
|
||||||
st.put(d)
|
st.put(d)
|
||||||
st.links = append(st.links, store.FileLink{
|
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",
|
Kind: "video", Status: "linked",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ func TestReconcileMatrix(t *testing.T) {
|
|||||||
if err := f.w.Poll(context.Background()); err != nil {
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
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)
|
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 {
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
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)
|
t.Errorf("state = %q, want done (healing)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,13 +88,13 @@ func TestReconcilePartialTargetLoss(t *testing.T) {
|
|||||||
f := newReconcileFixture(t, store.StateDone, true, true)
|
f := newReconcileFixture(t, store.StateDone, true, true)
|
||||||
missing := filepath.Join(filepath.Dir(f.dst), "Movie (2024).en.srt")
|
missing := filepath.Join(filepath.Dir(f.dst), "Movie (2024).en.srt")
|
||||||
f.st.links = append(f.st.links, store.FileLink{
|
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",
|
Kind: "subtitle", Status: "linked",
|
||||||
})
|
})
|
||||||
if err := f.w.Poll(context.Background()); err != nil {
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
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)
|
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 {
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
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)
|
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 {
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll %d: %v", i, err)
|
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)
|
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)
|
t.Errorf("tick %d: miss = %d, want %d", i, got, i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := f.w.Poll(context.Background()); err != nil { // третий промах
|
if err := f.w.Poll(context.Background()); err != nil { // третий промах
|
||||||
t.Fatalf("Poll 3: %v", err)
|
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)
|
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 {
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll heal: %v", err)
|
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)
|
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)
|
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 {
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
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)
|
t.Errorf("state = %q, want downloading (сверка не трогает активные)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUndoRejectedForOrphaned(t *testing.T) {
|
func TestUndoRejectedForOrphaned(t *testing.T) {
|
||||||
f := newReconcileFixture(t, store.StateOrphaned, false, true)
|
f := newReconcileFixture(t, store.StateOrphaned, false, true)
|
||||||
err := f.w.Undo(context.Background(), 1)
|
err := f.w.Undo(context.Background(), "1")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("ожидали отказ Undo для orphaned")
|
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)
|
t.Errorf("state = %q, want orphaned (без изменений)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,14 +173,14 @@ func TestUndoRejectedForOrphaned(t *testing.T) {
|
|||||||
func TestRelinkFromTargetMissing(t *testing.T) {
|
func TestRelinkFromTargetMissing(t *testing.T) {
|
||||||
// target_missing + источник на месте → relink ведёт в recognizing.
|
// target_missing + источник на месте → relink ведёт в recognizing.
|
||||||
f := newReconcileFixture(t, store.StateTargetMissing, true, false)
|
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)
|
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)
|
t.Errorf("state = %q, want recognizing", got)
|
||||||
}
|
}
|
||||||
if f.st.overrides[1][ovrForceReview] != "1" {
|
if f.st.overrides["1"][ovrForceReview] != "1" {
|
||||||
t.Errorf("force_review = %q, want 1", f.st.overrides[1][ovrForceReview])
|
t.Errorf("force_review = %q, want 1", f.st.overrides["1"][ovrForceReview])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,10 +189,10 @@ func TestPreflightFixesStaleState(t *testing.T) {
|
|||||||
// а цель на месте: relink немедленно приводит состояние к orphaned, не
|
// а цель на месте: relink немедленно приводит состояние к orphaned, не
|
||||||
// дожидаясь фоновой сверки.
|
// дожидаясь фоновой сверки.
|
||||||
f := newReconcileFixture(t, store.StateTargetMissing, false, true)
|
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 при пропавшем источнике")
|
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)
|
t.Errorf("state = %q, want orphaned (preflight привёл к реальности)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ import (
|
|||||||
var addedRecent = time.Date(2026, 6, 14, 9, 59, 0, 0, time.UTC).Unix()
|
var addedRecent = time.Date(2026, 6, 14, 9, 59, 0, 0, time.UTC).Unix()
|
||||||
|
|
||||||
func oneFailed(state store.State, code, infohash, createdAt string) *fakeStore {
|
func oneFailed(state store.State, code, infohash, createdAt string) *fakeStore {
|
||||||
return &fakeStore{downloads: map[int64]*store.Download{
|
return &fakeStore{downloads: map[string]*store.Download{
|
||||||
1: {
|
"1": {
|
||||||
ID: 1,
|
ID: "1",
|
||||||
State: state,
|
State: state,
|
||||||
SourceType: store.SourceMagnet,
|
SourceType: store.SourceMagnet,
|
||||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
||||||
Infohash: store.NullString(infohash),
|
Infohashes: hashesOf("1", infohash),
|
||||||
ErrorCode: store.NullString(code),
|
ErrorCode: store.NullString(code),
|
||||||
CreatedAt: createdAt,
|
CreatedAt: createdAt,
|
||||||
},
|
},
|
||||||
@@ -52,7 +52,7 @@ func TestRecovery(t *testing.T) {
|
|||||||
if err := w.Poll(context.Background()); err != nil {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
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)
|
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 {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if st.downloads[1].State != store.StateFailed {
|
if st.downloads["1"].State != store.StateFailed {
|
||||||
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads[1].State)
|
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads["1"].State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,11 +78,11 @@ func TestRecoveryNoSourceStaysFailed(t *testing.T) {
|
|||||||
func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
|
func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
|
||||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||||
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
||||||
st.downloads[2] = &store.Download{
|
st.downloads["2"] = &store.Download{
|
||||||
ID: 2,
|
ID: "2",
|
||||||
State: store.StateDownloading,
|
State: store.StateDownloading,
|
||||||
SourceType: store.SourceMagnet,
|
SourceType: store.SourceMagnet,
|
||||||
Infohash: store.NullString(ih),
|
Infohashes: hashesOf("2", ih),
|
||||||
CreatedAt: timeRecent,
|
CreatedAt: timeRecent,
|
||||||
}
|
}
|
||||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}}
|
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 {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if st.downloads[1].State != store.StateFailed {
|
if st.downloads["1"].State != store.StateFailed {
|
||||||
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads[1].State)
|
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads["1"].State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,11 +101,11 @@ func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
|
|||||||
func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
|
func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
|
||||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||||
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
||||||
st.downloads[2] = &store.Download{
|
st.downloads["2"] = &store.Download{
|
||||||
ID: 2,
|
ID: "2",
|
||||||
State: store.StateDownloading,
|
State: store.StateDownloading,
|
||||||
SourceType: store.SourceMagnet,
|
SourceType: store.SourceMagnet,
|
||||||
Infohash: store.NullString(ih),
|
Infohashes: hashesOf("2", ih),
|
||||||
CreatedAt: timeRecent,
|
CreatedAt: timeRecent,
|
||||||
}
|
}
|
||||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "uploading", AddedOn: addedRecent}}}
|
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 {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if st.downloads[1].State != store.StateFailed {
|
if st.downloads["1"].State != store.StateFailed {
|
||||||
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads[1].State)
|
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads["1"].State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ func TestFailNotifyDebounce(t *testing.T) {
|
|||||||
w := newTestWorker(st, &fakeQbt{})
|
w := newTestWorker(st, &fakeQbt{})
|
||||||
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
|
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
|
||||||
w.SetNotifier(n)
|
w.SetNotifier(n)
|
||||||
d := *st.downloads[1]
|
d := *st.downloads["1"]
|
||||||
|
|
||||||
w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
|
w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
|
||||||
if e := waitNotify(t, n); e.ev != EventFailed {
|
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}}}
|
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}}
|
||||||
w := newTestWorker(st, qb)
|
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)
|
t.Fatalf("Retry: %v", err)
|
||||||
}
|
}
|
||||||
if st.downloads[1].State != store.StateDownloading {
|
if st.downloads["1"].State != store.StateDownloading {
|
||||||
t.Fatalf("после retry ожидался downloading, got %q", st.downloads[1].State)
|
t.Fatalf("после retry ожидался downloading, got %q", st.downloads["1"].State)
|
||||||
}
|
}
|
||||||
if len(qb.added) != 0 {
|
if len(qb.added) != 0 {
|
||||||
t.Errorf("живой торрент не должен добавляться повторно, got %d Add", len(qb.added))
|
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 {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if st.downloads[1].State != store.StateDownloading {
|
if st.downloads["1"].State != store.StateDownloading {
|
||||||
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads[1].State)
|
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads["1"].State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+285
-85
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -50,7 +51,7 @@ func (w *Worker) recognizePending(ctx context.Context) {
|
|||||||
// под блокировкой переводим в recognizing, LLM зовём без блокировки, затем
|
// под блокировкой переводим в recognizing, LLM зовём без блокировки, затем
|
||||||
// под блокировкой фиксируем результат — но только если задачу за это время
|
// под блокировкой фиксируем результат — но только если задачу за это время
|
||||||
// не увели в другое состояние (cancel/defer).
|
// не увели в другое состояние (cancel/defer).
|
||||||
func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
func (w *Worker) recognizeOne(ctx context.Context, id string) {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
d, err := w.store.GetDownload(ctx, id)
|
d, err := w.store.GetDownload(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,7 +63,7 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
|||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx = w.scoped(ctx, capRecognize, id, d.Infohash.String)
|
ctx = w.scoped(ctx, capRecognize, id, d.PrimaryInfohash())
|
||||||
if d.State == store.StateCompleted {
|
if d.State == store.StateCompleted {
|
||||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||||
}
|
}
|
||||||
@@ -84,10 +85,10 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
|||||||
// затем зовёт распознаватель. Возвращает также savePath для маппинга
|
// затем зовёт распознаватель. Возвращает также savePath для маппинга
|
||||||
// относительных путей файлов в абсолютные при раскладке.
|
// относительных путей файлов в абсолютные при раскладке.
|
||||||
func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.Result, string, error) {
|
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")
|
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 {
|
if err != nil {
|
||||||
return recognize.Result{}, "", err
|
return recognize.Result{}, "", err
|
||||||
}
|
}
|
||||||
@@ -123,7 +124,7 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
|
|||||||
|
|
||||||
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
|
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
|
||||||
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review.
|
// метабазы выключены → авто-раскладки не делаем, всегда уходим в 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)
|
log := logctx.From(ctx)
|
||||||
planJSON, err := json.Marshal(res.Plan)
|
planJSON, err := json.Marshal(res.Plan)
|
||||||
if err != nil {
|
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)
|
log.Error("recognition persist failed", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// recognition_id — ключ корреляции попытки (грепается и голым ULID).
|
||||||
|
log.Info("recognition persisted", "recognition_id", recID,
|
||||||
|
"provider", provider, "provider_id", providerID)
|
||||||
// Кандидаты базы — для ручного выбора в review.
|
// Кандидаты базы — для ручного выбора в review.
|
||||||
if cands := toStoreCandidates(recID, res.Candidates); len(cands) > 0 {
|
if cands := toStoreCandidates(recID, res.Candidates); len(cands) > 0 {
|
||||||
if err := w.store.CreateCandidates(ctx, cands); err != nil {
|
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"
|
forceReview := overrides[ovrForceReview] == "1"
|
||||||
if res.Decision.Auto && !forceReview && w.layouter != nil {
|
if res.Decision.Auto && !forceReview && w.layouter != nil {
|
||||||
plan := applyOverrides(res.Plan, overrides)
|
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, "", "")
|
w.transition(lctx, *d, store.StateLinking, "", "")
|
||||||
if err := w.linkPlan(lctx, d, plan, tag, savePath); err != nil {
|
if err := w.linkPlan(lctx, d, plan, tag, savePath); err != nil {
|
||||||
logctx.From(lctx).Warn("auto-apply failed, left for review", "error", err)
|
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 читает правки, проглатывая ошибку (для авто-пути).
|
// 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)
|
o, err := w.store.ListOverrides(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logctx.From(ctx).Warn("recognition list overrides failed", "error", err)
|
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 создаёт хардлинки по текущему плану (с применёнными правками) и
|
// Apply создаёт хардлинки по текущему плану (с применёнными правками) и
|
||||||
// переводит задачу в done. Коллизия цели → остаёмся в review с причиной.
|
// переводит задачу в 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()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
if w.layouter == nil {
|
if w.layouter == nil {
|
||||||
@@ -225,15 +229,15 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
|
|||||||
return fmt.Errorf("apply: %w", err)
|
return fmt.Errorf("apply: %w", err)
|
||||||
}
|
}
|
||||||
if d.State != store.StateReview && d.State != store.StateDeferred {
|
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)
|
plan, tag, err := w.effectivePlan(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("apply: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("apply: lookup torrent: %w", err)
|
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,
|
DstPath: r.Link.Dst,
|
||||||
Kind: string(r.Link.Kind),
|
Kind: string(r.Link.Kind),
|
||||||
Status: string(r.Status),
|
Status: string(r.Status),
|
||||||
|
Size: r.Size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if len(fl) > 0 {
|
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, "", "")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +322,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
|||||||
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
|
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
|
||||||
// перепривязка всегда проходит через ревью с подтверждением (force_review).
|
// перепривязка всегда проходит через ревью с подтверждением (force_review).
|
||||||
// Источник (раздача в qBittorrent) для этого должен быть на месте.
|
// Источник (раздача в qBittorrent) для этого должен быть на месте.
|
||||||
func (w *Worker) Relink(ctx context.Context, id int64) error {
|
func (w *Worker) Relink(ctx context.Context, id string) error {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
@@ -326,28 +331,26 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
|
|||||||
return fmt.Errorf("relink: %w", err)
|
return fmt.Errorf("relink: %w", err)
|
||||||
}
|
}
|
||||||
if d.State != store.StateReverted && d.State != store.StateCancelled && d.State != store.StateTargetMissing {
|
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).
|
// его отсутствии приводим состояние к реальности (orphaned/deleted).
|
||||||
if err := w.ensureSourcePresent(ctx, d, "relink"); err != nil {
|
if err := w.ensureSourcePresent(ctx, d, "relink"); err != nil {
|
||||||
return err
|
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 {
|
if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil {
|
||||||
return fmt.Errorf("relink: %w", err)
|
return fmt.Errorf("relink: %w", err)
|
||||||
}
|
}
|
||||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
// Возврат в активную обработку — только через атомарный гард инварианта
|
||||||
|
// «не более одной активной задачи на 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)
|
logctx.From(ctx).Info("relink re-recognizing", "from", d.State)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -355,7 +358,7 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
|
|||||||
// Rerecognize перезапускает распознавание для задачи в review/deferred без
|
// Rerecognize перезапускает распознавание для задачи в review/deferred без
|
||||||
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
|
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
|
||||||
// цикл проведёт задачу recognizing → review заново.
|
// цикл проведёт задачу 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()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
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 {
|
if err := w.ensureSourcePresent(ctx, d, "rerecognize"); err != nil {
|
||||||
return err
|
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")
|
logctx.From(ctx).Info("review re-recognizing without hint")
|
||||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refine добавляет подсказку и отправляет задачу на перераспознавание.
|
// 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)
|
hint = strings.TrimSpace(hint)
|
||||||
if hint == "" {
|
if hint == "" {
|
||||||
return fmt.Errorf("refine: empty 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 {
|
if err := w.ensureSourcePresent(ctx, d, "refine"); err != nil {
|
||||||
return err
|
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 {
|
if err := w.store.AddHint(ctx, id, hint); err != nil {
|
||||||
return fmt.Errorf("refine: %w", err)
|
return fmt.Errorf("refine: %w", err)
|
||||||
}
|
}
|
||||||
@@ -399,7 +402,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
|||||||
|
|
||||||
// SetType фиксирует тип (override) и перезапускает распознавание с подсказкой
|
// SetType фиксирует тип (override) и перезапускает распознавание с подсказкой
|
||||||
// — чтобы LLM пересобрал роли файлов под новый тип.
|
// — чтобы 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) {
|
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
|
||||||
return fmt.Errorf("set type: invalid type %q", mediaType)
|
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 {
|
if err := w.ensureSourcePresent(ctx, d, "set type"); err != nil {
|
||||||
return err
|
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 {
|
if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil {
|
||||||
return fmt.Errorf("set type: %w", err)
|
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;
|
// 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)
|
src = strings.TrimSpace(src)
|
||||||
if src == "" {
|
if src == "" {
|
||||||
return fmt.Errorf("ignore: empty path")
|
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 {
|
if err := w.store.SetOverride(ctx, id, ovrIgnoredFiles, string(b)); err != nil {
|
||||||
return fmt.Errorf("ignore: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defer паркует задачу в deferred (вернётся в ревью по действию).
|
// 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()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
@@ -468,9 +471,9 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
|
|||||||
return fmt.Errorf("defer: %w", err)
|
return fmt.Errorf("defer: %w", err)
|
||||||
}
|
}
|
||||||
if d.State.IsTerminal() {
|
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, "", "")
|
w.transition(ctx, *d, store.StateDeferred, "", "")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -479,7 +482,7 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
|
|||||||
// Источник недосягаем (раскладчик удаляет только пути под библиотекой). Откат
|
// Источник недосягаем (раскладчик удаляет только пути под библиотекой). Откат
|
||||||
// снимает ЛИШНИЙ хардлинк, а не последнюю копию: layout.Undo отказывается
|
// снимает ЛИШНИЙ хардлинк, а не последнюю копию: layout.Undo отказывается
|
||||||
// удалять ссылку, если источник уже пропал (nlink<=1) — см. state-reconciliation.
|
// удалять ссылку, если источник уже пропал (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()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
if w.layouter == nil {
|
if w.layouter == nil {
|
||||||
@@ -496,9 +499,9 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
|
|||||||
return fmt.Errorf("undo: источник удалён, цель — последняя копия данных, откат невозможен: %w", ErrConflict)
|
return fmt.Errorf("undo: источник удалён, цель — последняя копия данных, откат невозможен: %w", ErrConflict)
|
||||||
}
|
}
|
||||||
if d.State != store.StateDone {
|
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)
|
batch, err := w.store.LatestBatchID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("undo: %w", err)
|
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)
|
return fmt.Errorf("undo: %w", err)
|
||||||
}
|
}
|
||||||
w.transition(ctx, *d, store.StateReverted, "", "")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireReviewable проверяет, что задача в review/deferred. Вызывается под mu.
|
// 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)
|
d, err := w.store.GetDownload(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", op, err)
|
return nil, fmt.Errorf("%s: %w", op, err)
|
||||||
}
|
}
|
||||||
if d.State != store.StateReview && d.State != store.StateDeferred {
|
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
|
return d, nil
|
||||||
}
|
}
|
||||||
@@ -549,7 +552,7 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
|
|||||||
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
|
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
|
||||||
// id, каноническое имя/год). Раскладку не запускает — превью обновится, а
|
// 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()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
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)
|
return fmt.Errorf("choose candidate: %w", err)
|
||||||
}
|
}
|
||||||
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
|
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}
|
// AddManualSource добавляет источник вручную по (provider, id) и выбирает его.
|
||||||
if cand.Title.Valid && cand.Title.String != "" {
|
// Когда автопоиск промахнулся: сохраняем кандидата (дедуп по provider:id) и
|
||||||
pins[ovrTitle] = cand.Title.String
|
// пиннит как выбранный. 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 {
|
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 {
|
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
|
||||||
return fmt.Errorf("choose candidate: %w", err)
|
return fmt.Errorf("choose candidate: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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)
|
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)
|
"provider", cand.Provider, "provider_id", cand.ProviderID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetProviderID пиннит провайдера и id вручную (без выбора из списка).
|
// 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))
|
provider = strings.TrimSpace(strings.ToLower(provider))
|
||||||
providerID = strings.TrimSpace(providerID)
|
providerID = strings.TrimSpace(providerID)
|
||||||
switch provider {
|
switch provider {
|
||||||
@@ -608,19 +690,21 @@ func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, provider
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := w.store.SetOverride(ctx, id, ovrProvider, provider); err != nil {
|
// Полный набор пинов: id задан вручную, название/год берём из плана
|
||||||
return fmt.Errorf("set provider: %w", err)
|
// (очищаем возможный унаследованный пин прежнего источника).
|
||||||
|
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 {
|
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider set",
|
||||||
return fmt.Errorf("set provider: %w", err)
|
|
||||||
}
|
|
||||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider set",
|
|
||||||
"provider", provider, "provider_id", providerID)
|
"provider", provider, "provider_id", providerID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearProvider — «без базы»: снимает матч (тег папки не ставится).
|
// ClearProvider — «без базы»: снимает матч (тег папки не ставится) и очищает
|
||||||
func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
|
// пины названия/года (источник — распознавание нейронкой).
|
||||||
|
func (w *Worker) ClearProvider(ctx context.Context, id string) error {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
@@ -628,16 +712,33 @@ func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := w.store.SetOverride(ctx, id, ovrProvider, "none"); err != nil {
|
for field, value := range sourcePins("none", "", "", 0) {
|
||||||
return fmt.Errorf("clear provider: %w", err)
|
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 {
|
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider cleared")
|
||||||
return fmt.Errorf("clear provider: %w", err)
|
|
||||||
}
|
|
||||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider cleared")
|
|
||||||
return nil
|
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 — всё, что нужно транспорту для отрисовки ревью.
|
// ReviewData — всё, что нужно транспорту для отрисовки ревью.
|
||||||
@@ -645,21 +746,46 @@ type ReviewData struct {
|
|||||||
Download store.Download
|
Download store.Download
|
||||||
Recognition *store.Recognition
|
Recognition *store.Recognition
|
||||||
Plan recognize.Plan // эффективный (с применёнными правками)
|
Plan recognize.Plan // эффективный (с применёнными правками)
|
||||||
Preview []layout.Link // целевые пути (Src — относительный, для показа)
|
Preview []layout.Link // целевые пути активного источника (Src — относительный)
|
||||||
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
|
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
|
||||||
|
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
|
||||||
Provider string // эффективный провайдер (с учётом выбора)
|
Provider string // эффективный провайдер (с учётом выбора)
|
||||||
ProviderID string // эффективный id в базе
|
ProviderID string // эффективный id в базе
|
||||||
Hints []string
|
Hints []string
|
||||||
Overrides map[string]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 собирает данные ревью по загрузке.
|
// 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)
|
d, err := w.store.GetDownload(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("review data: %w", err)
|
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)
|
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("review data: %w", err)
|
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 {
|
if rec != nil && rec.Plan.Valid {
|
||||||
var plan recognize.Plan
|
var rawPlan recognize.Plan
|
||||||
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
|
if err := json.Unmarshal([]byte(rec.Plan.String), &rawPlan); err != nil {
|
||||||
log.Warn("review data unmarshal plan failed", "error", err)
|
log.Warn("review data unmarshal plan failed", "error", err)
|
||||||
} else {
|
} else {
|
||||||
plan = applyOverrides(plan, overrides)
|
rd.Plan = applyOverrides(rawPlan, overrides)
|
||||||
rd.Plan = plan
|
// Превью активного источника строим по относительным путям с
|
||||||
// Превью строим по относительным путям с provider-тегом; ошибку
|
// provider-тегом; ошибку логируем на Debug — покажем без превью.
|
||||||
// логируем на Debug — просто покажем причины без превью.
|
|
||||||
if w.layouter != nil {
|
if w.layouter != nil {
|
||||||
tag := providerTag(prov, pid)
|
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
|
rd.Preview = links
|
||||||
} else {
|
} else {
|
||||||
log.Debug("review data build preview failed", "error", lerr)
|
log.Debug("review data build preview failed", "error", lerr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Единый список источников: нейронка + кандидаты, каждый с
|
||||||
|
// эфемерным превью из сырого плана (без записи overrides).
|
||||||
|
rd.Sources = w.buildSources(rawPlan, overrides, prov, pid, rd.Candidates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rd, nil
|
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 загружает текущий план, применяет правки и возвращает
|
// effectivePlan загружает текущий план, применяет правки и возвращает
|
||||||
// provider-тег для имени папки (под mu).
|
// 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)
|
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return recognize.Plan{}, "", err
|
return recognize.Plan{}, "", err
|
||||||
@@ -772,7 +969,7 @@ func effectiveProvider(rec *store.Recognition, overrides map[string]string) (pro
|
|||||||
|
|
||||||
// toStoreCandidates переводит кандидатов распознавания в строки БД,
|
// toStoreCandidates переводит кандидатов распознавания в строки БД,
|
||||||
// подставляя тег-предпочтительный provider/id (внешний из TVMaze и т.п.).
|
// подставляя тег-предпочтительный 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))
|
out := make([]store.MetadataCandidate, 0, len(cands))
|
||||||
for _, c := range cands {
|
for _, c := range cands {
|
||||||
prov, id := recognize.CandidateTag(c)
|
prov, id := recognize.CandidateTag(c)
|
||||||
@@ -863,19 +1060,22 @@ func mapRole(r recognize.FileRole) (layout.Role, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// torrentByInfohash ищет торрент по infohash (v1/v2/hash). Листаем ВСЕ
|
// torrentByInfohash ищет торрент по любому из хешей загрузки (v1/v2/hash).
|
||||||
// торренты (а не только свою категорию): раздача могла быть усыновлена по
|
// Листаем ВСЕ торренты (а не только свою категорию): раздача могла быть
|
||||||
// тегу и иметь чужую/пустую категорию — фильтр по категории её бы потерял
|
// усыновлена по тегу и иметь чужую/пустую категорию — фильтр по категории её
|
||||||
// (как и в Poll, см. там же).
|
// бы потерял (как и в Poll, см. там же).
|
||||||
func (w *Worker) torrentByInfohash(ctx context.Context, infohash string) (qbt.Torrent, bool, error) {
|
func (w *Worker) torrentByInfohash(ctx context.Context, hashes []string) (qbt.Torrent, bool, error) {
|
||||||
torrents, err := w.qbt.Torrents(ctx, "")
|
torrents, err := w.qbt.Torrents(ctx, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return qbt.Torrent{}, false, err
|
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 _, t := range torrents {
|
||||||
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
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
|
return t, true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+356
-168
File diff suppressed because it is too large
Load Diff
+110
-57
@@ -13,12 +13,15 @@ package worker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
@@ -39,34 +42,35 @@ const (
|
|||||||
type Store interface {
|
type Store interface {
|
||||||
ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error)
|
ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error)
|
||||||
ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error)
|
ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error)
|
||||||
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
GetDownload(ctx context.Context, id string) (*store.Download, error)
|
||||||
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
|
SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
||||||
SetSourceMissCount(ctx context.Context, id int64, n int) error
|
SetSourceMissCount(ctx context.Context, id string, n int) error
|
||||||
SetSourceAddedAt(ctx context.Context, id int64, t time.Time) error
|
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
|
||||||
|
|
||||||
// Discovery (усыновление раздач по категории/тегу).
|
// Идентичность/инвариант «одна активная загрузка на infohash».
|
||||||
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
|
ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error)
|
||||||
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
|
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error)
|
||||||
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
|
ActivateIfNoOtherActive(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
||||||
|
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
||||||
|
|
||||||
// Ф3: распознавание, ревью, раскладка.
|
// Ф3: распознавание, ревью, раскладка.
|
||||||
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (int64, error)
|
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (string, error)
|
||||||
GetCurrentRecognition(ctx context.Context, downloadID int64) (*store.Recognition, error)
|
GetCurrentRecognition(ctx context.Context, downloadID string) (*store.Recognition, error)
|
||||||
AddHint(ctx context.Context, downloadID int64, text string) error
|
AddHint(ctx context.Context, downloadID string, text string) error
|
||||||
ListHints(ctx context.Context, downloadID int64) ([]string, error)
|
ListHints(ctx context.Context, downloadID string) ([]string, error)
|
||||||
SetOverride(ctx context.Context, downloadID int64, field, value string) error
|
SetOverride(ctx context.Context, downloadID string, field, value string) error
|
||||||
ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error)
|
ListOverrides(ctx context.Context, downloadID string) (map[string]string, error)
|
||||||
CreateFileLinks(ctx context.Context, links []store.FileLink) error
|
CreateFileLinks(ctx context.Context, links []store.FileLink) error
|
||||||
SupersedeForeignLinks(ctx context.Context, downloadID int64, dstPaths []string) error
|
SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error
|
||||||
LatestBatchID(ctx context.Context, downloadID int64) (string, error)
|
LatestBatchID(ctx context.Context, downloadID string) (string, error)
|
||||||
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
|
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
|
||||||
DeleteFileLinksByBatch(ctx context.Context, batchID string) error
|
DeleteFileLinksByBatch(ctx context.Context, batchID string) error
|
||||||
|
|
||||||
// Кандидаты базы метаданных (ручной выбор в review).
|
// Кандидаты базы метаданных (ручной выбор в review).
|
||||||
CreateCandidates(ctx context.Context, cands []store.MetadataCandidate) error
|
CreateCandidates(ctx context.Context, cands []store.MetadataCandidate) error
|
||||||
ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]store.MetadataCandidate, error)
|
ListCandidatesByRecognition(ctx context.Context, recognitionID string) ([]store.MetadataCandidate, error)
|
||||||
GetCandidate(ctx context.Context, id int64) (*store.MetadataCandidate, error)
|
GetCandidate(ctx context.Context, id string) (*store.MetadataCandidate, error)
|
||||||
SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error
|
SetCandidateChosen(ctx context.Context, recognitionID, candidateID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// QBittorrent — нужная worker часть клиента qBittorrent.
|
// QBittorrent — нужная worker часть клиента qBittorrent.
|
||||||
@@ -111,7 +115,7 @@ const (
|
|||||||
|
|
||||||
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
|
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
|
||||||
type Notifier interface {
|
type Notifier interface {
|
||||||
Notify(ctx context.Context, downloadID int64, event NotifyEvent)
|
Notify(ctx context.Context, downloadID string, event NotifyEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scanner — триггер пересканирования медиатеки Jellyfin. Вызывается
|
// Scanner — триггер пересканирования медиатеки Jellyfin. Вызывается
|
||||||
@@ -146,26 +150,28 @@ type Live struct {
|
|||||||
State string // сырое состояние qBittorrent
|
State string // сырое состояние qBittorrent
|
||||||
Seeding bool // торрент завершён и раздаётся
|
Seeding bool // торрент завершён и раздаётся
|
||||||
|
|
||||||
Ratio float64 // рейтинг отдачи (может быть <0 = ∞/н/д)
|
TotalSize int64 // полный размер раздачи, байт (доступен для любой раздачи в снимке)
|
||||||
Seeds int // подключённые сиды
|
Ratio float64 // рейтинг отдачи (может быть <0 = ∞/н/д)
|
||||||
Peers int // подключённые личи
|
Seeds int // подключённые сиды
|
||||||
Uploaded int64 // отдано всего, байт
|
Peers int // подключённые личи
|
||||||
UpSpeed int64 // скорость отдачи, байт/с
|
Uploaded int64 // отдано всего, байт
|
||||||
|
UpSpeed int64 // скорость отдачи, байт/с
|
||||||
}
|
}
|
||||||
|
|
||||||
// liveFrom собирает Live из торрента qBittorrent (Seeding — через classify).
|
// liveFrom собирает Live из торрента qBittorrent (Seeding — через classify).
|
||||||
func liveFrom(t qbt.Torrent) Live {
|
func liveFrom(t qbt.Torrent) Live {
|
||||||
return Live{
|
return Live{
|
||||||
Progress: t.Progress,
|
Progress: t.Progress,
|
||||||
DlSpeed: t.Dlspeed,
|
DlSpeed: t.Dlspeed,
|
||||||
ETA: t.Eta,
|
ETA: t.Eta,
|
||||||
State: t.State,
|
State: t.State,
|
||||||
Seeding: classify(t.State) == classReady,
|
Seeding: classify(t.State) == classReady,
|
||||||
Ratio: t.Ratio,
|
TotalSize: t.TotalSize,
|
||||||
Seeds: t.NumSeeds,
|
Ratio: t.Ratio,
|
||||||
Peers: t.NumLeechs,
|
Seeds: t.NumSeeds,
|
||||||
Uploaded: t.Uploaded,
|
Peers: t.NumLeechs,
|
||||||
UpSpeed: t.Upspeed,
|
Uploaded: t.Uploaded,
|
||||||
|
UpSpeed: t.Upspeed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +202,7 @@ type Worker struct {
|
|||||||
// время последнего пинга). Мерцающий stalled-торрент колеблется
|
// время последнего пинга). Мерцающий stalled-торрент колеблется
|
||||||
// stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память
|
// stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память
|
||||||
// процесса: при рестарте дебаунс сбрасывается — допустимо. Доступ под w.mu.
|
// процесса: при рестарте дебаунс сбрасывается — допустимо. Доступ под w.mu.
|
||||||
failNotified map[int64]time.Time
|
failNotified map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// failNotifyDebounce — минимальный интервал между уведомлениями о падении
|
// failNotifyDebounce — минимальный интервал между уведомлениями о падении
|
||||||
@@ -221,7 +227,7 @@ func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log
|
|||||||
log: log,
|
log: log,
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
newID: defaultBatchID,
|
newID: defaultBatchID,
|
||||||
failNotified: map[int64]time.Time{},
|
failNotified: map[string]time.Time{},
|
||||||
live: map[string]Live{},
|
live: map[string]Live{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,15 +252,16 @@ func (w *Worker) setLive(snap map[string]Live) {
|
|||||||
w.liveMu.Unlock()
|
w.liveMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultBatchID — уникальный идентификатор батча раскладки.
|
// defaultBatchID — идентификатор батча раскладки (ULID, единая точка
|
||||||
|
// генерации id — internal/ident; сортируем по времени, удобен в логах).
|
||||||
func defaultBatchID() string {
|
func defaultBatchID() string {
|
||||||
return fmt.Sprintf("b-%d", time.Now().UnixNano())
|
return ident.NewID()
|
||||||
}
|
}
|
||||||
|
|
||||||
// scoped кладёт в ctx scoped-логгер загрузки (capability + download_id
|
// scoped кладёт в ctx scoped-логгер загрузки (capability + download_id
|
||||||
// [+ infohash]); стадии и внешние клиенты достают его из ctx и дописывают эти
|
// [+ infohash]); стадии и внешние клиенты достают его из ctx и дописывают эти
|
||||||
// ключи на каждую запись сами — без ручного доклеивания download_id.
|
// ключи на каждую запись сами — без ручного доклеивания 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)
|
log := w.log.With("capability", capability, "download_id", id)
|
||||||
if infohash != "" {
|
if infohash != "" {
|
||||||
log = log.With("infohash", 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)
|
return fmt.Errorf("poll: list active: %w", err)
|
||||||
}
|
}
|
||||||
for _, d := range active {
|
for _, d := range active {
|
||||||
if !d.Infohash.Valid {
|
if len(d.Infohashes) == 0 {
|
||||||
continue // нечем сопоставить (в Ф1 не случается: magnet всегда с infohash)
|
continue // нечем сопоставить (в Ф1 не случается: magnet всегда с infohash)
|
||||||
}
|
}
|
||||||
t, ok := byHash[strings.ToLower(d.Infohash.String)]
|
t, ok := torrentFor(d, byHash)
|
||||||
if !ok {
|
if !ok {
|
||||||
w.log.Warn("active download not found in qbittorrent",
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
w.captureInfohashes(ctx, d, t)
|
||||||
w.captureSourceAddedAt(ctx, d, t)
|
w.captureSourceAddedAt(ctx, d, t)
|
||||||
w.reconcile(ctx, d, t)
|
w.reconcile(ctx, d, t)
|
||||||
}
|
}
|
||||||
@@ -351,7 +359,7 @@ func (w *Worker) Poll(ctx context.Context) error {
|
|||||||
// reconcile двигает одну задачу по состоянию её торрента. Вызывается под
|
// reconcile двигает одну задачу по состоянию её торрента. Вызывается под
|
||||||
// w.mu.
|
// w.mu.
|
||||||
func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent) {
|
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) {
|
switch classify(t.State) {
|
||||||
case classReady:
|
case classReady:
|
||||||
w.transition(ctx, d, store.StateCompleted, "", "")
|
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 (надёжный базис,
|
// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис,
|
||||||
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
|
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
|
||||||
// отдал added_on.
|
// отдал added_on.
|
||||||
@@ -453,7 +494,7 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
|
|||||||
// Скан Jellyfin — неблокирующе и вне w.mu, в фоновом ctx со scoped-логгером
|
// Скан Jellyfin — неблокирующе и вне w.mu, в фоновом ctx со scoped-логгером
|
||||||
// (download_id для корреляции ext.*-записи клиента). Недоступность Jellyfin
|
// (download_id для корреляции ext.*-записи клиента). Недоступность Jellyfin
|
||||||
// на задачу не влияет; ошибку вызова логирует сам клиент (ext.*), здесь гасим.
|
// на задачу не влияет; ошибку вызова логирует сам клиент (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) }()
|
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), чтобы не спамить. Вызывается
|
// (мерцающий stalled-торрент: stuck↔downloading), чтобы не спамить. Вызывается
|
||||||
// под w.mu. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы
|
// под w.mu. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы
|
||||||
// флаппинг.
|
// флаппинг.
|
||||||
func (w *Worker) shouldNotifyFail(id int64) bool {
|
func (w *Worker) shouldNotifyFail(id string) bool {
|
||||||
now := w.now()
|
now := w.now()
|
||||||
if last, ok := w.failNotified[id]; ok && now.Sub(last) < failNotifyDebounce {
|
if last, ok := w.failNotified[id]; ok && now.Sub(last) < failNotifyDebounce {
|
||||||
return false
|
return false
|
||||||
@@ -479,7 +520,7 @@ func (w *Worker) shouldNotifyFail(id int64) bool {
|
|||||||
|
|
||||||
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
|
// 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()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
@@ -488,18 +529,18 @@ func (w *Worker) Cancel(ctx context.Context, id int64) error {
|
|||||||
return fmt.Errorf("cancel: %w", err)
|
return fmt.Errorf("cancel: %w", err)
|
||||||
}
|
}
|
||||||
if d.State.IsTerminal() {
|
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 {
|
if err := w.store.SetDownloadState(ctx, id, store.StateCancelled, "", ""); err != nil {
|
||||||
return fmt.Errorf("cancel: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в
|
// Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в
|
||||||
// qBittorrent и возвращает в downloading.
|
// 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()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
@@ -508,32 +549,44 @@ func (w *Worker) Retry(ctx context.Context, id int64) error {
|
|||||||
return fmt.Errorf("retry: %w", err)
|
return fmt.Errorf("retry: %w", err)
|
||||||
}
|
}
|
||||||
if d.State != store.StateFailed && d.State != store.StateStuck {
|
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
|
// Если раздача уже жива в qBittorrent — перецепляемся к ней, повторный Add
|
||||||
// не нужен (и вреден: вслепую дублировал бы торрент). Add — только когда
|
// не нужен (и вреден: вслепую дублировал бы торрент). Add — только когда
|
||||||
// источника в qBittorrent нет. Базис таймаута берётся от added_on, поэтому
|
// источника в qBittorrent нет. Базис таймаута берётся от added_on, поэтому
|
||||||
// возврат в downloading не роняет задачу снова на ближайшем тике.
|
// возврат в downloading не роняет задачу снова на ближайшем тике.
|
||||||
alive := false
|
alive := false
|
||||||
if d.Infohash.Valid {
|
if hashes := d.HashList(); len(hashes) > 0 {
|
||||||
_, alive, err = w.torrentByInfohash(ctx, d.Infohash.String)
|
_, alive, err = w.torrentByInfohash(ctx, hashes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("retry: %w", err)
|
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 !alive && d.SourceType == store.SourceMagnet {
|
||||||
if err := w.qbt.Add(ctx, qbt.AddRequest{
|
if err := w.qbt.Add(ctx, qbt.AddRequest{
|
||||||
URLs: []string{d.SourceRef},
|
URLs: []string{d.SourceRef},
|
||||||
Category: w.cfg.Category,
|
Category: w.cfg.Category,
|
||||||
SavePath: w.cfg.SavePath,
|
SavePath: w.cfg.SavePath,
|
||||||
}); err != nil {
|
}); 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)
|
return fmt.Errorf("retry: add to qbittorrent: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := w.store.SetDownloadState(ctx, id, store.StateDownloading, "", ""); err != nil {
|
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download retried", "from", d.State)
|
||||||
return fmt.Errorf("retry: %w", err)
|
|
||||||
}
|
|
||||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("download retried", "from", d.State)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+168
-50
@@ -2,6 +2,7 @@ package worker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -14,18 +15,18 @@ import (
|
|||||||
|
|
||||||
// фиксированные метки времени для детерминированных таймаут-тестов.
|
// фиксированные метки времени для детерминированных таймаут-тестов.
|
||||||
const (
|
const (
|
||||||
timeNow = "2026-06-14 10:00:00"
|
timeNow = "2026-06-14T10:00:00Z"
|
||||||
timeOld = "2026-06-14 08:00:00" // 2 часа назад
|
timeOld = "2026-06-14T08:00:00Z" // 2 часа назад
|
||||||
timeRecent = "2026-06-14 09:59:00" // 1 минута назад
|
timeRecent = "2026-06-14T09:59:00Z" // 1 минута назад
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeStore struct {
|
type fakeStore struct {
|
||||||
downloads map[int64]*store.Download
|
downloads map[string]*store.Download
|
||||||
transitions []transition
|
transitions []transition
|
||||||
}
|
}
|
||||||
|
|
||||||
type transition struct {
|
type transition struct {
|
||||||
id int64
|
id string
|
||||||
state store.State
|
state store.State
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,27 +59,39 @@ func (f *fakeStore) ListRecoverable(_ context.Context, codes ...string) ([]store
|
|||||||
return out, nil
|
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]
|
d, ok := f.downloads[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("download %d not found", id)
|
return nil, fmt.Errorf("download %s not found", id)
|
||||||
}
|
}
|
||||||
cp := *d
|
cp := *d
|
||||||
return &cp, nil
|
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 {
|
for _, d := range f.downloads {
|
||||||
if d.Infohash.Valid && d.Infohash.String == infohash {
|
if hasAnyHash(d, hashes) {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, 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 {
|
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
|
cp := *d
|
||||||
return &cp, nil
|
return &cp, nil
|
||||||
}
|
}
|
||||||
@@ -86,18 +99,62 @@ func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*s
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
|
func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
||||||
id := int64(len(f.downloads) + 1)
|
if existing, _ := f.FindActiveByInfohash(ctx, hashes...); existing != nil {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
id := fmt.Sprintf("%d", len(f.downloads)+1)
|
||||||
cp := *d
|
cp := *d
|
||||||
cp.ID = id
|
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
|
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]
|
d, ok := f.downloads[id]
|
||||||
if !ok {
|
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.State = st
|
||||||
d.ErrorCode = store.NullString(code)
|
d.ErrorCode = store.NullString(code)
|
||||||
@@ -106,19 +163,19 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State
|
|||||||
return nil
|
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]
|
d, ok := f.downloads[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("download %d not found", id)
|
return fmt.Errorf("download %s not found", id)
|
||||||
}
|
}
|
||||||
d.SourceMissCount = n
|
d.SourceMissCount = n
|
||||||
return nil
|
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]
|
d, ok := f.downloads[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("download %d not found", id)
|
return fmt.Errorf("download %s not found", id)
|
||||||
}
|
}
|
||||||
if !d.SourceAddedAt.Valid { // гард как в store: пишем однократно
|
if !d.SourceAddedAt.Valid { // гард как в store: пишем однократно
|
||||||
d.SourceAddedAt = store.NullString(store.FormatTime(t))
|
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) ---
|
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
|
||||||
|
|
||||||
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) {
|
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (string, error) {
|
||||||
return 0, nil
|
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
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (f *fakeStore) AddHint(_ context.Context, _ int64, _ string) error { return nil }
|
func (f *fakeStore) AddHint(_ context.Context, _ string, _ string) error { return nil }
|
||||||
func (f *fakeStore) ListHints(_ context.Context, _ int64) ([]string, error) { return nil, nil }
|
func (f *fakeStore) ListHints(_ context.Context, _ string) ([]string, error) { return nil, nil }
|
||||||
func (f *fakeStore) SetOverride(_ context.Context, _ int64, _, _ string) error { return nil }
|
func (f *fakeStore) SetOverride(_ context.Context, _ string, _, _ string) error { return nil }
|
||||||
func (f *fakeStore) ListOverrides(_ context.Context, _ int64) (map[string]string, error) {
|
func (f *fakeStore) ListOverrides(_ context.Context, _ string) (map[string]string, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return 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
|
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) {
|
func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.FileLink, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -152,17 +209,18 @@ func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error {
|
|||||||
func (f *fakeStore) CreateCandidates(_ context.Context, _ []store.MetadataCandidate) error {
|
func (f *fakeStore) CreateCandidates(_ context.Context, _ []store.MetadataCandidate) error {
|
||||||
return nil
|
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
|
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
|
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 {
|
type fakeQbt struct {
|
||||||
torrents []qbt.Torrent
|
torrents []qbt.Torrent
|
||||||
added []qbt.AddRequest
|
added []qbt.AddRequest
|
||||||
|
addErr error
|
||||||
files []qbt.File
|
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 {
|
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
|
||||||
|
if f.addErr != nil {
|
||||||
|
return f.addErr
|
||||||
|
}
|
||||||
f.added = append(f.added, ar)
|
f.added = append(f.added, ar)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -204,18 +265,28 @@ func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func oneDownloading(infohash, createdAt string) *fakeStore {
|
func oneDownloading(infohash, createdAt string) *fakeStore {
|
||||||
return &fakeStore{downloads: map[int64]*store.Download{
|
return &fakeStore{downloads: map[string]*store.Download{
|
||||||
1: {
|
"1": {
|
||||||
ID: 1,
|
ID: "1",
|
||||||
State: store.StateDownloading,
|
State: store.StateDownloading,
|
||||||
SourceType: store.SourceMagnet,
|
SourceType: store.SourceMagnet,
|
||||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
||||||
Infohash: store.NullString(infohash),
|
Infohashes: hashesOf("1", infohash),
|
||||||
CreatedAt: createdAt,
|
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) {
|
func TestPollTransitions(t *testing.T) {
|
||||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -242,7 +313,7 @@ func TestPollTransitions(t *testing.T) {
|
|||||||
if err := w.Poll(context.Background()); err != nil {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
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)
|
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 {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if st.downloads[1].State != store.StateCompleted {
|
if st.downloads["1"].State != store.StateCompleted {
|
||||||
t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads[1].State)
|
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 {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if st.downloads[1].State != store.StateDownloading {
|
if st.downloads["1"].State != store.StateDownloading {
|
||||||
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads[1].State)
|
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads["1"].State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCancel(t *testing.T) {
|
func TestCancel(t *testing.T) {
|
||||||
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||||
w := newTestWorker(st, &fakeQbt{})
|
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)
|
t.Fatalf("Cancel: %v", err)
|
||||||
}
|
}
|
||||||
if st.downloads[1].State != store.StateCancelled {
|
if st.downloads["1"].State != store.StateCancelled {
|
||||||
t.Errorf("state = %q, want cancelled", st.downloads[1].State)
|
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("ожидалась ошибка при отмене терминальной задачи")
|
t.Error("ожидалась ошибка при отмене терминальной задачи")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRetry(t *testing.T) {
|
func TestRetry(t *testing.T) {
|
||||||
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||||
st.downloads[1].State = store.StateStuck
|
st.downloads["1"].State = store.StateStuck
|
||||||
qb := &fakeQbt{}
|
qb := &fakeQbt{}
|
||||||
w := newTestWorker(st, qb)
|
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)
|
t.Fatalf("Retry: %v", err)
|
||||||
}
|
}
|
||||||
if st.downloads[1].State != store.StateDownloading {
|
if st.downloads["1"].State != store.StateDownloading {
|
||||||
t.Errorf("state = %q, want downloading", st.downloads[1].State)
|
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
|
||||||
}
|
}
|
||||||
if len(qb.added) != 1 {
|
if len(qb.added) != 1 {
|
||||||
t.Errorf("ожидалось повторное добавление в qBittorrent, got %d", len(qb.added))
|
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) {
|
func TestRetryRejectsActive(t *testing.T) {
|
||||||
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||||
w := newTestWorker(st, &fakeQbt{})
|
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) задачи должен отклоняться")
|
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/`.
|
||||||
+84
@@ -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** переходы применяются последовательно под блокировкой, без гонки
|
||||||
+83
@@ -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`, в лог пишется предупреждение
|
||||||
+19
@@ -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 на
|
||||||
|
него ссылаются.
|
||||||
+102
@@ -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
|
||||||
+122
@@ -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 доступен без повторной
|
||||||
|
генерации
|
||||||
+49
@@ -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** автор загрузки получает уведомление о рассинхроне
|
||||||
+118
@@ -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`.
|
||||||
+164
@@ -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 на страницу ревью той же загрузки
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
### Requirement: Уведомление о рассинхроне
|
||||||
|
|
||||||
|
**Reason**: Уведомления автора — единое поведение, собранное в capability
|
||||||
|
`notifications`; здесь оно дублировало эту заботу.
|
||||||
|
**Migration**: Требование перенесено без изменений в `notifications` (см.
|
||||||
|
`specs/notifications`). Сама сверка и переходы `orphaned`/`target_missing`
|
||||||
|
остаются в `state-reconciliation`.
|
||||||
+18
@@ -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`).
|
||||||
+24
@@ -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** суммарный размер разложенных файлов доступен как размер раздачи для
|
||||||
|
показа в карточке
|
||||||
+28
@@ -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** в снимке для неё доступны рейтинг, число сидов/пиров, объём
|
||||||
|
отданного и скорость отдачи
|
||||||
+79
@@ -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
Reference in New Issue
Block a user