Идентичность на ULID: download_infohash, guarded-дедуп, миграция (ulid-identity)

Все сущности переехали с INTEGER AUTOINCREMENT на TEXT ULID (lowercase,
internal/ident — единая точка генерации и разбора; oklog/ulid). Инфохэши
загрузки — множество (download_infohash, v1/v2 гибридных торрентов): дедуп
и сопоставление в поллинге по любому из хешей, magnet-парсер отдаёт оба
хеша гибридной ссылки, усечённый v2-хеш v2-only раздач не хранится.

Инвариант «не более одной активной загрузки на infohash» вместо снятого
unique-индекса держат guarded-методы store в одной write-транзакции
(_txlock=immediate): CreateDownloadIfNoActive (приём/adopt, с доносом
недостающих хешей), ActivateIfNoOtherActive (retry/recovery/relink, отказ
до побочных эффектов), guarded AddInfohashes; SetDownloadState отклоняет
терминал→активное как механический бэкстоп.

Миграция 0006 — первая Go-миграция goose: пересоздание таблиц при
включённых FK, backfill ULID с timestamp из created_at (хронология id
сохранена), разнос infohash, удаление idempotency_key. BREAKING: формат id
в URL/логах/Telegram, REST-поля id (string) и infohashes (список).

Новая конвенция docs/conventions/database.md (без числовых PK), корреляция
в логах grep'ом по голому ULID, ER-схема обновлена. Спеки: новая capability
identity, MODIFIED в state-reconciliation; change заархивирован. Пройдены
ревью дизайна и кода (по 8 углов), все находки исправлены с
регрессионными тестами.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
av
2026-07-02 21:25:00 +03:00
co-authored by Claude Fable 5
parent b808ceff25
commit 37f2f6481a
53 changed files with 3640 additions and 1035 deletions
+6 -3
View File
@@ -124,9 +124,12 @@ Module path — `git.vakhrushev.me/av/jellybit`. Go 1.26, `CGO_ENABLED=0`.
файл (`config.toml` не коммитится, `0600`), не в env; валидация на старте: файл (`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.
+1 -1
View File
@@ -184,7 +184,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)
+3
View File
@@ -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 + нормализация на границах,
естественные ключи у деталей.
+47
View File
@@ -0,0 +1,47 @@
# Конвенция: база данных и идентификаторы
Как мы устраиваем таблицы и ключи в 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 DEFAULT (datetime('now'))` (UTC), формат
`store.ParseTime`/`FormatTime`.
- Миграции — goose (`internal/store/migrations`): SQL-файлы для DDL;
Go-миграции (`goose.AddMigrationContext`) — когда нужен код (генерация
id, backfill). При изменении структуры обновляем ER-схему
[../specs/database.md](../specs/database.md) в том же change.
+13 -8
View File
@@ -18,7 +18,7 @@ OpenSpec-спеках (`### Requirement` с `SHALL`).
фильтрацию и агрегацию через `jq`/DuckDB без регулярок. фильтрацию и агрегацию через `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`.
## Ошибки ## Ошибки
+37 -21
View File
@@ -10,15 +10,19 @@
> документации. > документации.
> >
> Состояние на: миграции `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`).
Назначение таблиц и почему так — [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).
## 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,14 +30,12 @@ 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)"
@@ -42,9 +44,16 @@ erDiagram
TEXT updated_at "NOT NULL DEFAULT datetime('now')" TEXT updated_at "NOT NULL DEFAULT datetime('now')"
} }
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 DEFAULT datetime('now')"
}
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"
@@ -61,23 +70,23 @@ erDiagram
} }
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 DEFAULT datetime('now')"
} }
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 DEFAULT datetime('now')"
} }
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"
@@ -88,8 +97,8 @@ erDiagram
} }
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; целевой хардлинк"
@@ -101,10 +110,16 @@ erDiagram
## Связи и кардинальность ## Связи и кардинальность
- `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 +127,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
View File
@@ -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
+3
View File
@@ -30,6 +30,9 @@ github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4 h1
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4/go.mod h1:H66QhXPJpUSdWschhL6u//v3ge96/qMnQ9mWp3efbxA= github.com/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=
+9 -6
View File
@@ -12,12 +12,13 @@ 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 Infohash string // первый хеш (для шапки)
InfohashShort string InfohashShort string
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
Context string Context string
State string State string
Error string Error string
@@ -65,7 +66,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)
@@ -85,8 +87,9 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
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, Infohash: d.PrimaryInfohash(),
InfohashShort: shortenHash(d.Infohash.String), InfohashShort: shortenHash(d.PrimaryInfohash()),
Infohashes: d.HashList(),
Context: d.Context, Context: d.Context,
State: string(d.State), State: string(d.State),
Error: d.ErrorMsg.String, Error: d.ErrorMsg.String,
@@ -126,7 +129,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)
+51 -39
View File
@@ -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,15 @@ 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)
} }
// Deps — зависимости транспорта. // Deps — зависимости транспорта.
@@ -178,7 +179,7 @@ type pageLink struct {
} }
type downloadView struct { type downloadView struct {
ID int64 ID string
Title string // отображаемый заголовок карточки Title string // отображаемый заголовок карточки
Infohash string // полный (для копирования) Infohash string // полный (для копирования)
InfohashShort string // усечённый (для показа) InfohashShort string // усечённый (для показа)
@@ -269,7 +270,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
// Живой прогресс активных загрузок — со значениями уже в первом кадре // Живой прогресс активных загрузок — со значениями уже в первом кадре
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом. // (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
if v.IsDownloading { if v.IsDownloading {
l, ok := s.deps.Live.Live(d.Infohash.String) l, ok := s.liveFor(d)
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 +395,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 +412,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 +434,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 +450,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 +467,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 +481,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 +497,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 +509,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,
@@ -521,8 +524,8 @@ func toView(d store.Download) downloadView {
return downloadView{ return downloadView{
ID: d.ID, ID: d.ID,
Title: downloadTitle(d), Title: downloadTitle(d),
Infohash: d.Infohash.String, Infohash: d.PrimaryInfohash(),
InfohashShort: shortenHash(d.Infohash.String), InfohashShort: shortenHash(d.PrimaryInfohash()),
Context: d.Context, Context: d.Context,
State: state, State: state,
Error: d.ErrorMsg.String, Error: d.ErrorMsg.String,
@@ -586,12 +589,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) {
@@ -627,9 +639,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 +650,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 +658,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()))
} }
+101 -91
View File
@@ -21,6 +21,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 +41,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 +54,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,7 +84,7 @@ 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
} }
@@ -98,7 +106,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 +120,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 +146,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 +162,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 +172,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 +180,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 +189,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 +197,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 +207,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 +217,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 +249,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 +311,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 +326,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 +339,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 +357,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 +376,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,81 +394,81 @@ 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 typed map[string]string
ignored map[int64]string ignored map[string]string
chosen map[int64]int64 chosen map[string]string
providerSet map[int64]string providerSet 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) 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) IgnoreFile(_ context.Context, id int64, src string) error { func (f *fakeReviewer) IgnoreFile(_ context.Context, id string, 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) ClearProvider(_ context.Context, id string) error {
f.cleared = append(f.cleared, id) f.cleared = append(f.cleared, id)
return nil return nil
} }
@@ -466,9 +476,9 @@ func (f *fakeReviewer) ClearProvider(_ context.Context, id int64) error {
func seriesReviewData() *worker.ReviewData { func seriesReviewData() *worker.ReviewData {
s, e := 2, 1 s, e := 2, 1
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{ Plan: recognize.Plan{
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015, Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
@@ -480,9 +490,9 @@ func seriesReviewData() *worker.ReviewData {
{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"}, {Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
}, },
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")},
}, },
Hints: []string{"второй сезон"}, Hints: []string{"второй сезон"},
} }
@@ -500,7 +510,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)
} }
@@ -529,7 +539,7 @@ func TestReviewShowsMatchLink(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)
} }
@@ -545,16 +555,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,18 +575,18 @@ 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)
} }
} }
@@ -586,7 +596,7 @@ func TestApplyRedirectsToIndex(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)
} }
@@ -607,13 +617,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,16 +632,16 @@ 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)
} }
} }
@@ -642,20 +652,20 @@ func TestIgnoreAndType(t *testing.T) {
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", if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/type",
map[string][]string{"type": {"movie"}}); err != nil { map[string][]string{"type": {"movie"}}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if rv.typed[1] != "movie" { if rv.typed[tid] != "movie" {
t.Errorf("SetType получил %q", rv.typed[1]) t.Errorf("SetType получил %q", rv.typed[tid])
} }
} }
@@ -665,10 +675,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 +692,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 +706,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)
} }
} }
+9 -9
View File
@@ -27,7 +27,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 +39,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 +49,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 +64,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 +82,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 +91,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 +99,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 +107,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
+16 -12
View File
@@ -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)
}
} }
+43 -18
View File
@@ -23,7 +23,7 @@ func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { r
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
} }
@@ -33,23 +33,23 @@ func (s stubReader) GetDownload(context.Context, int64) (*store.Download, error)
// 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) 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 +85,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 +104,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 +127,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)
} }
} }
+29 -26
View File
@@ -7,30 +7,31 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"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 SetType(ctx context.Context, id string, mediaType string) error
IgnoreFile(ctx context.Context, id int64, src string) error IgnoreFile(ctx context.Context, id string, src string) error
Defer(ctx context.Context, id int64) error Defer(ctx context.Context, id string) error
Undo(ctx context.Context, id int64) error Undo(ctx context.Context, id string) error
Relink(ctx context.Context, id int64) error Relink(ctx context.Context, id string) error
Rerecognize(ctx context.Context, id int64) error Rerecognize(ctx context.Context, id string) error
ChooseCandidate(ctx context.Context, id, candidateID int64) error ChooseCandidate(ctx context.Context, id, candidateID string) error
SetProviderID(ctx context.Context, id int64, provider, providerID string) error SetProviderID(ctx context.Context, id string, 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
@@ -55,7 +56,7 @@ type reviewView struct {
} }
type candidateView struct { type candidateView struct {
ID int64 ID string
Provider string Provider string
ProviderID string ProviderID string
Title string Title string
@@ -67,7 +68,8 @@ type candidateView struct {
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)
@@ -145,36 +147,37 @@ 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) { func (s *server) handleSetType(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.SetType(ctx, id, r.PostForm.Get("type")) 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.reviewAction(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,14 +186,14 @@ 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.reviewAction(w, r, func(ctx context.Context, id string) error {
return s.deps.Reviewer.ClearProvider(ctx, id) return s.deps.Reviewer.ClearProvider(ctx, id)
}) })
} }
@@ -240,7 +243,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")
@@ -296,8 +299,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)
} }
+50
View File
@@ -0,0 +1,50 @@
// Package ident — единственная точка генерации и разбора идентификаторов
// домена. Идентификатор — ULID (26 символов Crockford base32), канонический
// вид — lowercase; на входных границах (URL, формы) значение прогоняется
// через Parse до обращения к хранилищу, потому что сравнение строк в SQLite
// побайтовое.
package ident
import (
"crypto/rand"
"fmt"
"strings"
"sync"
"time"
"github.com/oklog/ulid/v2"
)
// entropy — monotonic-источник: при равной миллисекунде timestamp'а энтропия
// инкрементируется, так что id, выданные подряд, сохраняют порядок выдачи.
var (
mu sync.Mutex
entropy = ulid.Monotonic(rand.Reader, 0)
)
// NewID возвращает новый идентификатор (текущее время).
func NewID() string {
return NewIDAt(time.Now())
}
// NewIDAt возвращает идентификатор с timestamp-частью из t. Используется
// миграциями для бэкфилла: сортировка id сохраняет историческую хронологию
// (created_at имеет секундное разрешение — равные метки упорядочивает
// monotonic-энтропия в порядке вызовов).
func NewIDAt(t time.Time) string {
mu.Lock()
defer mu.Unlock()
id := ulid.MustNew(ulid.Timestamp(t.UTC()), entropy)
return strings.ToLower(id.String())
}
// Parse валидирует внешний идентификатор и нормализует его к каноническому
// lowercase-виду. Регистр входа не важен (base32 ULID case-insensitive).
func Parse(s string) (string, error) {
s = strings.TrimSpace(s)
u, err := ulid.ParseStrict(strings.ToUpper(s))
if err != nil {
return "", fmt.Errorf("ident: parse %q: %w", s, err)
}
return strings.ToLower(u.String()), nil
}
+70
View File
@@ -0,0 +1,70 @@
package ident
import (
"strings"
"testing"
"time"
)
func TestNewIDLowercaseAndValid(t *testing.T) {
id := NewID()
if len(id) != 26 {
t.Fatalf("len(%q) = %d, want 26", id, len(id))
}
if id != strings.ToLower(id) {
t.Fatalf("id %q is not lowercase", id)
}
if _, err := Parse(id); err != nil {
t.Fatalf("Parse(NewID()) failed: %v", err)
}
}
func TestNewIDSortedByIssueOrder(t *testing.T) {
prev := NewID()
for range 100 {
next := NewID()
if next <= prev {
t.Fatalf("ids out of order: %q then %q", prev, next)
}
prev = next
}
}
func TestNewIDAtEqualTimestampsKeepOrder(t *testing.T) {
ts := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
prev := NewIDAt(ts)
for range 100 {
next := NewIDAt(ts) // одна и та же миллисекунда → monotonic-энтропия
if next <= prev {
t.Fatalf("ids out of order at equal timestamp: %q then %q", prev, next)
}
prev = next
}
}
func TestNewIDAtChronology(t *testing.T) {
older := NewIDAt(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
newer := NewIDAt(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC))
if older >= newer {
t.Fatalf("chronology broken: %q >= %q", older, newer)
}
}
func TestParseNormalizesCase(t *testing.T) {
id := NewID()
got, err := Parse(strings.ToUpper(id))
if err != nil {
t.Fatalf("Parse(upper) failed: %v", err)
}
if got != id {
t.Fatalf("Parse(upper) = %q, want %q", got, id)
}
}
func TestParseRejectsGarbage(t *testing.T) {
for _, bad := range []string{"", "abc", "abc!!!", "123", strings.Repeat("z", 26), strings.Repeat("a", 27)} {
if _, err := Parse(bad); err == nil {
t.Fatalf("Parse(%q) unexpectedly succeeded", bad)
}
}
}
+61 -24
View File
@@ -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,
}
}
+55 -18
View File
@@ -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("уведомление о падении приёма не пришло")
+10 -6
View File
@@ -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
+18
View File
@@ -47,6 +47,24 @@ func TestParse(t *testing.T) {
} }
} }
// Гибридный magnet несёт оба хеша: Infohash — v1 (приоритет), Infohashes — оба.
func TestParseHybridKeepsBothHashes(t *testing.T) {
const (
v1 = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
)
got, err := Parse("magnet:?xt=urn:btih:" + v1 + "&xt=urn:btmh:1220" + v2)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got.Infohash != v1 {
t.Errorf("primary = %q, want v1", got.Infohash)
}
if len(got.Infohashes) != 2 || got.Infohashes[0] != v1 || got.Infohashes[1] != v2 {
t.Errorf("infohashes = %v, want [v1 v2]", got.Infohashes)
}
}
func TestParseErrors(t *testing.T) { func TestParseErrors(t *testing.T) {
cases := []string{ cases := []string{
"https://example.com/file.torrent", // не magnet "https://example.com/file.torrent", // не magnet
+382 -78
View File
@@ -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,18 +72,44 @@ 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"`
@@ -91,12 +120,34 @@ type Download struct {
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"`
} }
// HashList — все хеши загрузки списком (для сопоставления с qBittorrent).
func (d Download) HashList() []string {
out := make([]string, len(d.Infohashes))
for i, h := range d.Infohashes {
out[i] = h.Infohash
}
return out
}
// PrimaryInfohash — первый известный хеш (v1 приоритетно) для показа и
// scoped-логгера; пустая строка, если хешей нет.
func (d Download) PrimaryInfohash() string {
if len(d.Infohashes) == 0 {
return ""
}
return d.Infohashes[0].Infohash
}
// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC). // sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC).
const sqliteTimeLayout = "2006-01-02 15:04:05" const sqliteTimeLayout = "2006-01-02 15:04:05"
@@ -121,31 +172,163 @@ 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")
}
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) VALUES (?, ?, ?)`,
existing.ID, h, HashKind(h)); 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)
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); 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) VALUES (?, ?, ?)`,
d.ID, h, HashKind(h)); 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
}
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) VALUES (?, ?, ?)`,
downloadID, h, HashKind(h)); 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 +336,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 +373,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 +400,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 +455,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 = datetime('now') updated_at = datetime('now')
WHERE id = ?` WHERE id = ?`
terminal := 0 args := []any{string(state), nullArg(errCode), nullArg(errMsg), 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
View File
@@ -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) VALUES (?, ?, 'v1')`,
newer, h); 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
View File
@@ -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")
+5 -1
View File
@@ -74,7 +74,8 @@ func listWhere(f ListFilter) (string, []any) {
conds = append(conds, "(source_ref LIKE ? COLLATE NOCASE "+ conds = append(conds, "(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 "+
"WHERE dh.download_id = download.id AND dh.infohash LIKE ? COLLATE NOCASE))")
args = append(args, like, like, like, like) args = append(args, like, like, like, like)
} }
@@ -109,5 +110,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
View File
@@ -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)
+142
View File
@@ -0,0 +1,142 @@
package store
import (
"context"
"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)
}
if one.CreatedAt != "2026-01-01 10:00:00" || one.UpdatedAt != "2026-01-01 11:00:00" {
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)
}
}
}
@@ -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()
}
+43 -44
View File
@@ -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,50 @@ 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)
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); err != nil {
if err != nil { return "", fmt.Errorf("insert recognition: %w", err)
return 0, fmt.Errorf("insert recognition: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("recognition last insert id: %w", err)
} }
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 +103,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) VALUES (?, ?, ?)`,
ident.NewID(), downloadID, text); 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 +125,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) 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); 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,8 +159,8 @@ 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"`
@@ -182,11 +181,11 @@ func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error {
defer func() { _ = tx.Rollback() }() defer func() { _ = tx.Rollback() }()
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)
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); err != nil {
return fmt.Errorf("insert file_link: %w", err) return fmt.Errorf("insert file_link: %w", err)
} }
} }
@@ -202,7 +201,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 +225,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 = ?
@@ -265,8 +264,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"`
@@ -288,11 +287,11 @@ func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate)
defer func() { _ = tx.Rollback() }() defer func() { _ = tx.Rollback() }()
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)
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); err != nil {
return fmt.Errorf("insert candidate: %w", err) return fmt.Errorf("insert candidate: %w", err)
} }
} }
@@ -303,7 +302,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 +312,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 -39
View File
@@ -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)
@@ -163,11 +158,7 @@ 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 +235,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 +253,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)
} }
+8 -1
View File
@@ -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
View File
@@ -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]
+59 -40
View File
@@ -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, 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,7 +253,7 @@ 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, "Готово") { if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Готово") {
t.Errorf("sent = %+v", api.sent) t.Errorf("sent = %+v", api.sent)
@@ -260,7 +263,7 @@ 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, "не удалась") { if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") {
t.Errorf("sent = %+v", api.sent) t.Errorf("sent = %+v", api.sent)
@@ -273,20 +276,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)
} }
} }
+13 -16
View File
@@ -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 {
@@ -90,7 +89,7 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
func (b *Bot) renderDone(rd *worker.ReviewData) string { func (b *Bot) renderDone(rd *worker.ReviewData) string {
title := rd.Plan.Title title := rd.Plan.Title
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 {
@@ -103,7 +102,7 @@ func (b *Bot) renderDone(rd *worker.ReviewData) string {
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 := rd.Plan.Title
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 +122,7 @@ 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) 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 +138,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 +149,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 +160,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 +232,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
View File
@@ -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
} }
+63 -17
View File
@@ -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)
} }
} }
+1 -1
View File
@@ -22,7 +22,7 @@ func TestPollBuildsLiveSnapshot(t *testing.T) {
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)
+22 -29
View File
@@ -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)
} }
+21 -21
View File
@@ -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)
} }
} }
+23 -23
View File
@@ -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)
} }
} }
+64 -60
View File
@@ -50,7 +50,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 +62,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 +84,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 +123,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 +174,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 +192,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 +203,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 +216,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 +228,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)
} }
@@ -308,7 +311,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 +320,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 +329,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 +356,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 +367,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 +389,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 +400,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 +414,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 +431,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 +455,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 +469,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 +480,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 +497,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 +529,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 +550,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,7 +567,7 @@ 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)
} }
pins := map[string]string{ovrProvider: cand.Provider, ovrProviderID: cand.ProviderID} pins := map[string]string{ovrProvider: cand.Provider, ovrProviderID: cand.ProviderID}
@@ -584,13 +585,13 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) err
if err := w.store.SetCandidateChosen(ctx, rec.ID, candidateID); err != nil { if err := w.store.SetCandidateChosen(ctx, rec.ID, candidateID); 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 {
@@ -614,13 +615,13 @@ func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, provider
if err := w.store.SetOverride(ctx, id, ovrProviderID, providerID); err != nil { if err := w.store.SetOverride(ctx, id, ovrProviderID, providerID); err != nil {
return fmt.Errorf("set provider: %w", err) return fmt.Errorf("set provider: %w", err)
} }
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider set", logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider set",
"provider", provider, "provider_id", providerID) "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()
@@ -634,7 +635,7 @@ func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
if err := w.store.SetOverride(ctx, id, ovrProviderID, ""); err != nil { if err := w.store.SetOverride(ctx, id, ovrProviderID, ""); err != nil {
return fmt.Errorf("clear provider: %w", err) return fmt.Errorf("clear provider: %w", err)
} }
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider cleared") logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider cleared")
return nil return nil
} }
@@ -654,12 +655,12 @@ type ReviewData struct {
} }
// 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)
@@ -709,7 +710,7 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
// 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 +773,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 +864,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
} }
} }
+204 -168
View File
@@ -20,12 +20,12 @@ import (
// recordingNotifier ловит события пинга (Notify асинхронен — через канал). // recordingNotifier ловит события пинга (Notify асинхронен — через канал).
type notifyEvent struct { type notifyEvent struct {
id int64 id string
ev NotifyEvent ev NotifyEvent
} }
type recordingNotifier struct{ ch chan notifyEvent } type recordingNotifier struct{ ch chan notifyEvent }
func (n *recordingNotifier) Notify(_ context.Context, id int64, ev NotifyEvent) { func (n *recordingNotifier) Notify(_ context.Context, id string, ev NotifyEvent) {
n.ch <- notifyEvent{id, ev} n.ch <- notifyEvent{id, ev}
} }
@@ -42,7 +42,7 @@ func waitNotify(t *testing.T, n *recordingNotifier) notifyEvent {
func TestNotifier_FiresOnReview(t *testing.T) { func TestNotifier_FiresOnReview(t *testing.T) {
st := newMemStore() st := newMemStore()
st.put(completedDownload(1)) st.put(completedDownload("1"))
qb := &fakeQbt{ qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}, torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}}, files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}},
@@ -51,10 +51,10 @@ func TestNotifier_FiresOnReview(t *testing.T) {
n := &recordingNotifier{ch: make(chan notifyEvent, 4)} n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
w.SetNotifier(n) w.SetNotifier(n)
w.recognizeOne(context.Background(), 1) w.recognizeOne(context.Background(), "1")
e := waitNotify(t, n) e := waitNotify(t, n)
if e.id != 1 || e.ev != EventReview { if e.id != "1" || e.ev != EventReview {
t.Errorf("event = %+v, want {1 review}", e) t.Errorf("event = %+v, want {1 review}", e)
} }
} }
@@ -64,11 +64,11 @@ func TestNotifier_FiresOnDone(t *testing.T) {
n := &recordingNotifier{ch: make(chan notifyEvent, 4)} n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
f.w.SetNotifier(n) f.w.SetNotifier(n)
if err := f.w.Apply(context.Background(), 1); err != nil { if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
e := waitNotify(t, n) e := waitNotify(t, n)
if e.id != 1 || e.ev != EventDone { if e.id != "1" || e.ev != EventDone {
t.Errorf("event = %+v, want {1 done}", e) t.Errorf("event = %+v, want {1 done}", e)
} }
} }
@@ -87,7 +87,7 @@ func TestScanner_FiresOnDone(t *testing.T) {
s := &recordingScanner{ch: make(chan struct{}, 4)} s := &recordingScanner{ch: make(chan struct{}, 4)}
f.w.SetScanner(s) f.w.SetScanner(s)
if err := f.w.Apply(context.Background(), 1); err != nil { if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
select { select {
@@ -97,7 +97,7 @@ func TestScanner_FiresOnDone(t *testing.T) {
} }
} }
func revertedDownload(id int64) *store.Download { func revertedDownload(id string) *store.Download {
d := completedDownload(id) d := completedDownload(id)
d.State = store.StateReverted d.State = store.StateReverted
return d return d
@@ -105,90 +105,90 @@ func revertedDownload(id int64) *store.Download {
func TestRelink_RevertedToRecognizing(t *testing.T) { func TestRelink_RevertedToRecognizing(t *testing.T) {
st := newMemStore() st := newMemStore()
st.put(revertedDownload(1)) st.put(revertedDownload("1"))
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}} qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil) w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
if err := w.Relink(context.Background(), 1); err != nil { if err := w.Relink(context.Background(), "1"); err != nil {
t.Fatalf("Relink: %v", err) t.Fatalf("Relink: %v", err)
} }
if st.downloads[1].State != store.StateRecognizing { if st.downloads["1"].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads[1].State) t.Fatalf("state = %q, want recognizing", st.downloads["1"].State)
} }
if st.overrides[1][ovrForceReview] != "1" { if st.overrides["1"][ovrForceReview] != "1" {
t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview]) t.Errorf("force_review override = %q, want 1", st.overrides["1"][ovrForceReview])
} }
} }
func TestRelink_CancelledToRecognizing(t *testing.T) { func TestRelink_CancelledToRecognizing(t *testing.T) {
st := newMemStore() st := newMemStore()
d := revertedDownload(1) d := revertedDownload("1")
d.State = store.StateCancelled d.State = store.StateCancelled
st.put(d) st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}} qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil) w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
if err := w.Relink(context.Background(), 1); err != nil { if err := w.Relink(context.Background(), "1"); err != nil {
t.Fatalf("Relink: %v", err) t.Fatalf("Relink: %v", err)
} }
if st.downloads[1].State != store.StateRecognizing { if st.downloads["1"].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads[1].State) t.Fatalf("state = %q, want recognizing", st.downloads["1"].State)
} }
if st.overrides[1][ovrForceReview] != "1" { if st.overrides["1"][ovrForceReview] != "1" {
t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview]) t.Errorf("force_review override = %q, want 1", st.overrides["1"][ovrForceReview])
} }
} }
func TestRelink_RejectsActiveState(t *testing.T) { func TestRelink_RejectsActiveState(t *testing.T) {
st := newMemStore() st := newMemStore()
st.put(completedDownload(1)) // не reverted/cancelled st.put(completedDownload("1")) // не reverted/cancelled
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}} qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Relink(context.Background(), 1); err == nil { if err := w.Relink(context.Background(), "1"); err == nil {
t.Fatal("ожидали ошибку для не-reverted/cancelled задачи, получили nil") t.Fatal("ожидали ошибку для не-reverted/cancelled задачи, получили nil")
} }
} }
func TestRerecognize_ReviewToRecognizing(t *testing.T) { func TestRerecognize_ReviewToRecognizing(t *testing.T) {
st := newMemStore() st := newMemStore()
d := completedDownload(1) d := completedDownload("1")
d.State = store.StateReview d.State = store.StateReview
st.put(d) st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}} qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Rerecognize(context.Background(), 1); err != nil { if err := w.Rerecognize(context.Background(), "1"); err != nil {
t.Fatalf("Rerecognize: %v", err) t.Fatalf("Rerecognize: %v", err)
} }
if st.downloads[1].State != store.StateRecognizing { if st.downloads["1"].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads[1].State) t.Fatalf("state = %q, want recognizing", st.downloads["1"].State)
} }
} }
func TestRerecognize_RejectsNonReview(t *testing.T) { func TestRerecognize_RejectsNonReview(t *testing.T) {
st := newMemStore() st := newMemStore()
st.put(completedDownload(1)) // completed, не review/deferred st.put(completedDownload("1")) // completed, не review/deferred
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
if err := w.Rerecognize(context.Background(), 1); err == nil { if err := w.Rerecognize(context.Background(), "1"); err == nil {
t.Fatal("ожидали ошибку для не-review задачи, получили nil") t.Fatal("ожидали ошибку для не-review задачи, получили nil")
} }
} }
func TestRelink_TorrentMissing(t *testing.T) { func TestRelink_TorrentMissing(t *testing.T) {
st := newMemStore() st := newMemStore()
st.put(revertedDownload(1)) st.put(revertedDownload("1"))
qb := &fakeQbt{torrents: nil} // раздачи в qBittorrent нет qb := &fakeQbt{torrents: nil} // раздачи в qBittorrent нет
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Relink(context.Background(), 1); err == nil { if err := w.Relink(context.Background(), "1"); err == nil {
t.Fatal("ожидали ошибку при отсутствии торрента, получили nil") t.Fatal("ожидали ошибку при отсутствии торрента, получили nil")
} }
// Preflight приводит состояние к реальности: источника нет и цели нет // Preflight приводит состояние к реальности: источника нет и цели нет
// (reverted — ссылки сняты) → deleted (см. state-reconciliation). // (reverted — ссылки сняты) → deleted (см. state-reconciliation).
if st.downloads[1].State != store.StateDeleted { if st.downloads["1"].State != store.StateDeleted {
t.Errorf("state = %q, want deleted (preflight привёл к реальности)", st.downloads[1].State) t.Errorf("state = %q, want deleted (preflight привёл к реальности)", st.downloads["1"].State)
} }
} }
@@ -197,21 +197,21 @@ func TestRelink_TorrentMissing(t *testing.T) {
func TestRelink_ForceReviewSkipsAuto(t *testing.T) { func TestRelink_ForceReviewSkipsAuto(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan) f := newApplyFixture(t, seriesResult().Plan)
// Готовим состояние «как после Relink»: reverted, force_review выставлен. // Готовим состояние «как после Relink»: reverted, force_review выставлен.
f.st.downloads[1].State = store.StateReverted f.st.downloads["1"].State = store.StateReverted
_ = f.st.SetOverride(context.Background(), 1, ovrForceReview, "1") _ = f.st.SetOverride(context.Background(), "1", ovrForceReview, "1")
auto := seriesResult() auto := seriesResult()
auto.Decision.Auto = true auto.Decision.Auto = true
auto.Match = &recognize.Match{Provider: "tvdb", ProviderID: "42"} auto.Match = &recognize.Match{Provider: "tvdb", ProviderID: "42"}
f.w.recognizer = &fakeRecognizer{result: auto} f.w.recognizer = &fakeRecognizer{result: auto}
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)
} }
f.w.recognizeOne(context.Background(), 1) f.w.recognizeOne(context.Background(), "1")
if f.st.downloads[1].State != store.StateReview { if f.st.downloads["1"].State != store.StateReview {
t.Fatalf("state = %q, want review (авто-раскладка не должна сработать)", f.st.downloads[1].State) t.Fatalf("state = %q, want review (авто-раскладка не должна сработать)", f.st.downloads["1"].State)
} }
if len(f.st.links) != 0 { if len(f.st.links) != 0 {
t.Errorf("file_links = %d, want 0 (ничего не линковали)", len(f.st.links)) t.Errorf("file_links = %d, want 0 (ничего не линковали)", len(f.st.links))
@@ -220,19 +220,19 @@ func TestRelink_ForceReviewSkipsAuto(t *testing.T) {
// memStore — полноценный in-memory store для тестов Ф3. // memStore — полноценный in-memory store для тестов Ф3.
type memStore struct { type memStore struct {
downloads map[int64]*store.Download downloads map[string]*store.Download
recs []*store.Recognition recs []*store.Recognition
hints map[int64][]string hints map[string][]string
overrides map[int64]map[string]string overrides map[string]map[string]string
links []store.FileLink links []store.FileLink
candidates []store.MetadataCandidate candidates []store.MetadataCandidate
} }
func newMemStore() *memStore { func newMemStore() *memStore {
return &memStore{ return &memStore{
downloads: map[int64]*store.Download{}, downloads: map[string]*store.Download{},
hints: map[int64][]string{}, hints: map[string][]string{},
overrides: map[int64]map[string]string{}, overrides: map[string]map[string]string{},
} }
} }
@@ -266,18 +266,18 @@ func (m *memStore) ListRecoverable(_ context.Context, codes ...string) ([]store.
return out, nil return out, nil
} }
func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) { func (m *memStore) ExistsByInfohash(_ context.Context, hashes ...string) (bool, error) {
for _, d := range m.downloads { for _, d := range m.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 (m *memStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) { func (m *memStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*store.Download, error) {
for _, d := range m.downloads { for _, d := range m.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
} }
@@ -285,15 +285,51 @@ func (m *memStore) FindActiveByInfohash(_ context.Context, infohash string) (*st
return nil, nil return nil, nil
} }
func (m *memStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) { func (m *memStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) {
id := int64(len(m.downloads) + 1) if existing, _ := m.FindActiveByInfohash(ctx, hashes...); existing != nil {
return existing, nil
}
id := itoa(len(m.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)})
}
m.downloads[id] = &cp m.downloads[id] = &cp
return id, nil d.ID = id
d.Infohashes = cp.Infohashes
return nil, nil
} }
func (m *memStore) GetDownload(_ context.Context, id int64) (*store.Download, error) { func (m *memStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
d, ok := m.downloads[id]
if !ok {
return os.ErrNotExist
}
for _, other := range m.downloads {
if other.ID != id && !other.State.IsTerminal() && hasAnyHash(other, hashList(d)) {
return store.ErrInfohashTaken
}
}
return m.SetDownloadState(ctx, id, st, code, msg)
}
func (m *memStore) AddInfohashes(_ context.Context, id string, hashes []string) error {
d, ok := m.downloads[id]
if !ok {
return os.ErrNotExist
}
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 (m *memStore) GetDownload(_ context.Context, id string) (*store.Download, error) {
d, ok := m.downloads[id] d, ok := m.downloads[id]
if !ok { if !ok {
return nil, os.ErrNotExist return nil, os.ErrNotExist
@@ -302,7 +338,7 @@ func (m *memStore) GetDownload(_ context.Context, id int64) (*store.Download, er
return &cp, nil return &cp, nil
} }
func (m *memStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error { func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
d := m.downloads[id] d := m.downloads[id]
d.State = st d.State = st
d.ErrorCode = store.NullString(code) d.ErrorCode = store.NullString(code)
@@ -310,28 +346,28 @@ func (m *memStore) SetDownloadState(_ context.Context, id int64, st store.State,
return nil return nil
} }
func (m *memStore) SetSourceMissCount(_ context.Context, id int64, n int) error { func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error {
if d, ok := m.downloads[id]; ok { if d, ok := m.downloads[id]; ok {
d.SourceMissCount = n d.SourceMissCount = n
} }
return nil return nil
} }
func (m *memStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) error { func (m *memStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) error {
if d, ok := m.downloads[id]; ok && !d.SourceAddedAt.Valid { if d, ok := m.downloads[id]; ok && !d.SourceAddedAt.Valid {
d.SourceAddedAt = store.NullString(store.FormatTime(t)) d.SourceAddedAt = store.NullString(store.FormatTime(t))
} }
return nil return nil
} }
func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (int64, error) { func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (string, error) {
for _, e := range m.recs { for _, e := range m.recs {
if e.DownloadID == r.DownloadID { if e.DownloadID == r.DownloadID {
e.IsCurrent = false e.IsCurrent = false
} }
} }
cp := *r cp := *r
cp.ID = int64(len(m.recs) + 1) cp.ID = itoa(len(m.recs) + 1)
cp.IsCurrent = true cp.IsCurrent = true
cp.AttemptNo = 1 cp.AttemptNo = 1
for _, e := range m.recs { for _, e := range m.recs {
@@ -345,7 +381,7 @@ func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, re
return cp.ID, nil return cp.ID, nil
} }
func (m *memStore) GetCurrentRecognition(_ context.Context, downloadID int64) (*store.Recognition, error) { func (m *memStore) GetCurrentRecognition(_ context.Context, downloadID string) (*store.Recognition, error) {
for _, e := range m.recs { for _, e := range m.recs {
if e.DownloadID == downloadID && e.IsCurrent { if e.DownloadID == downloadID && e.IsCurrent {
cp := *e cp := *e
@@ -355,20 +391,20 @@ func (m *memStore) GetCurrentRecognition(_ context.Context, downloadID int64) (*
return nil, nil return nil, nil
} }
func (m *memStore) AddHint(_ context.Context, id int64, text string) error { func (m *memStore) AddHint(_ context.Context, id string, text string) error {
m.hints[id] = append(m.hints[id], text) m.hints[id] = append(m.hints[id], text)
return nil return nil
} }
func (m *memStore) ListHints(_ context.Context, id int64) ([]string, error) { return m.hints[id], nil } func (m *memStore) ListHints(_ context.Context, id string) ([]string, error) { return m.hints[id], nil }
func (m *memStore) SetOverride(_ context.Context, id int64, field, value string) error { func (m *memStore) SetOverride(_ context.Context, id string, field, value string) error {
if m.overrides[id] == nil { if m.overrides[id] == nil {
m.overrides[id] = map[string]string{} m.overrides[id] = map[string]string{}
} }
m.overrides[id][field] = value m.overrides[id][field] = value
return nil return nil
} }
func (m *memStore) ListOverrides(_ context.Context, id int64) (map[string]string, error) { func (m *memStore) ListOverrides(_ context.Context, id string) (map[string]string, error) {
return m.overrides[id], nil return m.overrides[id], nil
} }
@@ -376,7 +412,7 @@ func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) er
m.links = append(m.links, links...) m.links = append(m.links, links...)
return nil return nil
} }
func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID int64, dstPaths []string) error { func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID string, dstPaths []string) error {
if len(dstPaths) == 0 { if len(dstPaths) == 0 {
return nil return nil
} }
@@ -395,7 +431,7 @@ func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID int64, ds
} }
return nil return nil
} }
func (m *memStore) LatestBatchID(_ context.Context, id int64) (string, error) { func (m *memStore) LatestBatchID(_ context.Context, id string) (string, error) {
for i := len(m.links) - 1; i >= 0; i-- { for i := len(m.links) - 1; i >= 0; i-- {
if m.links[i].DownloadID == id { if m.links[i].DownloadID == id {
return m.links[i].ApplyBatchID, nil return m.links[i].ApplyBatchID, nil
@@ -425,12 +461,12 @@ func (m *memStore) DeleteFileLinksByBatch(_ context.Context, batch string) error
func (m *memStore) CreateCandidates(_ context.Context, cands []store.MetadataCandidate) error { func (m *memStore) CreateCandidates(_ context.Context, cands []store.MetadataCandidate) error {
for _, c := range cands { for _, c := range cands {
c.ID = int64(len(m.candidates) + 1) c.ID = itoa(len(m.candidates) + 1)
m.candidates = append(m.candidates, c) m.candidates = append(m.candidates, c)
} }
return nil return nil
} }
func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID int64) ([]store.MetadataCandidate, error) { func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID string) ([]store.MetadataCandidate, error) {
var out []store.MetadataCandidate var out []store.MetadataCandidate
for _, c := range m.candidates { for _, c := range m.candidates {
if c.RecognitionID == recID { if c.RecognitionID == recID {
@@ -439,7 +475,7 @@ func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID int64) (
} }
return out, nil return out, nil
} }
func (m *memStore) GetCandidate(_ context.Context, id int64) (*store.MetadataCandidate, error) { func (m *memStore) GetCandidate(_ context.Context, id string) (*store.MetadataCandidate, error) {
for i := range m.candidates { for i := range m.candidates {
if m.candidates[i].ID == id { if m.candidates[i].ID == id {
cp := m.candidates[i] cp := m.candidates[i]
@@ -448,7 +484,7 @@ func (m *memStore) GetCandidate(_ context.Context, id int64) (*store.MetadataCan
} }
return nil, nil return nil, nil
} }
func (m *memStore) SetCandidateChosen(_ context.Context, recID, id int64) error { func (m *memStore) SetCandidateChosen(_ context.Context, recID, id string) error {
for i := range m.candidates { for i := range m.candidates {
if m.candidates[i].RecognitionID == recID { if m.candidates[i].RecognitionID == recID {
m.candidates[i].Chosen = m.candidates[i].ID == id m.candidates[i].Chosen = m.candidates[i].ID == id
@@ -501,10 +537,10 @@ func itoa(n int) string {
const ihTest = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" const ihTest = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
func completedDownload(id int64) *store.Download { func completedDownload(id string) *store.Download {
return &store.Download{ return &store.Download{
ID: id, State: store.StateCompleted, SourceType: store.SourceMagnet, ID: id, State: store.StateCompleted, SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + ihTest, Infohash: store.NullString(ihTest), SourceRef: "magnet:?xt=urn:btih:" + ihTest, Infohashes: hashesOf(id, ihTest),
Context: "ctx", Context: "ctx",
} }
} }
@@ -526,7 +562,7 @@ func seriesResult() recognize.Result {
func TestRecognizeOne_CompletedToReview(t *testing.T) { func TestRecognizeOne_CompletedToReview(t *testing.T) {
st := newMemStore() st := newMemStore()
st.put(completedDownload(1)) st.put(completedDownload("1"))
qb := &fakeQbt{ qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", Category: "jellybit"}}, torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", Category: "jellybit"}},
files: []qbt.File{{Name: "Show/e1.mkv", Size: 100}, {Name: "Show/e2.mkv", Size: 100}}, files: []qbt.File{{Name: "Show/e1.mkv", Size: 100}, {Name: "Show/e2.mkv", Size: 100}},
@@ -534,12 +570,12 @@ func TestRecognizeOne_CompletedToReview(t *testing.T) {
rec := &fakeRecognizer{result: seriesResult()} rec := &fakeRecognizer{result: seriesResult()}
w := testWorkerWith(st, qb, rec, nil) w := testWorkerWith(st, qb, rec, nil)
w.recognizeOne(context.Background(), 1) w.recognizeOne(context.Background(), "1")
if st.downloads[1].State != store.StateReview { if st.downloads["1"].State != store.StateReview {
t.Fatalf("state = %q, want review", st.downloads[1].State) t.Fatalf("state = %q, want review", st.downloads["1"].State)
} }
cur, _ := st.GetCurrentRecognition(context.Background(), 1) cur, _ := st.GetCurrentRecognition(context.Background(), "1")
if cur == nil || cur.Title.String != "Show" { if cur == nil || cur.Title.String != "Show" {
t.Fatalf("recognition = %+v", cur) t.Fatalf("recognition = %+v", cur)
} }
@@ -554,7 +590,7 @@ func TestRecognizeOne_CompletedToReview(t *testing.T) {
// распознавание падало с «torrent not found in qBittorrent». // распознавание падало с «torrent not found in qBittorrent».
func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) { func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
st := newMemStore() st := newMemStore()
st.put(completedDownload(1)) st.put(completedDownload("1"))
qb := &fakeQbt{ qb := &fakeQbt{
torrents: []qbt.Torrent{{ torrents: []qbt.Torrent{{
Hash: ihTest, Name: "ThePitt", SavePath: "/d", Hash: ihTest, Name: "ThePitt", SavePath: "/d",
@@ -565,14 +601,14 @@ func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
rec := &fakeRecognizer{result: seriesResult()} rec := &fakeRecognizer{result: seriesResult()}
w := testWorkerWith(st, qb, rec, nil) w := testWorkerWith(st, qb, rec, nil)
w.recognizeOne(context.Background(), 1) w.recognizeOne(context.Background(), "1")
if st.downloads[1].State != store.StateReview { if st.downloads["1"].State != store.StateReview {
t.Fatalf("state = %q, want review", st.downloads[1].State) t.Fatalf("state = %q, want review", st.downloads["1"].State)
} }
// Recognizer вернул бы Title="Show" только если торрент найден по infohash; // Recognizer вернул бы Title="Show" только если торрент найден по infohash;
// при потере (фильтр по категории) был бы пустой план с причиной «not found». // при потере (фильтр по категории) был бы пустой план с причиной «not found».
cur, _ := st.GetCurrentRecognition(context.Background(), 1) cur, _ := st.GetCurrentRecognition(context.Background(), "1")
if cur == nil || cur.Title.String != "Show" { if cur == nil || cur.Title.String != "Show" {
t.Fatalf("recognizer did not run on found torrent (title=%q): torrent must be found by infohash despite foreign category", t.Fatalf("recognizer did not run on found torrent (title=%q): torrent must be found by infohash despite foreign category",
func() string { func() string {
@@ -586,40 +622,40 @@ func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) { func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) {
st := newMemStore() st := newMemStore()
st.put(completedDownload(1)) st.put(completedDownload("1"))
qb := &fakeQbt{ qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}, torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
files: []qbt.File{{Name: "Show/e1.mkv", Size: 100}}, files: []qbt.File{{Name: "Show/e1.mkv", Size: 100}},
} }
// Во время вызова LLM задачу отменяют. // Во время вызова LLM задачу отменяют.
rec := &fakeRecognizer{result: seriesResult(), onCall: func() { rec := &fakeRecognizer{result: seriesResult(), onCall: func() {
st.downloads[1].State = store.StateCancelled st.downloads["1"].State = store.StateCancelled
}} }}
w := testWorkerWith(st, qb, rec, nil) w := testWorkerWith(st, qb, rec, nil)
w.recognizeOne(context.Background(), 1) w.recognizeOne(context.Background(), "1")
if st.downloads[1].State != store.StateCancelled { if st.downloads["1"].State != store.StateCancelled {
t.Errorf("state = %q, want cancelled (result discarded)", st.downloads[1].State) t.Errorf("state = %q, want cancelled (result discarded)", st.downloads["1"].State)
} }
if cur, _ := st.GetCurrentRecognition(context.Background(), 1); cur != nil { if cur, _ := st.GetCurrentRecognition(context.Background(), "1"); cur != nil {
t.Error("recognition must not be persisted after discard") t.Error("recognition must not be persisted after discard")
} }
} }
func TestRecognizeOne_SignalsErrorToReview(t *testing.T) { func TestRecognizeOne_SignalsErrorToReview(t *testing.T) {
st := newMemStore() st := newMemStore()
st.put(completedDownload(1)) st.put(completedDownload("1"))
qb := &fakeQbt{torrents: nil} // торрент пропал qb := &fakeQbt{torrents: nil} // торрент пропал
rec := &fakeRecognizer{result: seriesResult()} rec := &fakeRecognizer{result: seriesResult()}
w := testWorkerWith(st, qb, rec, nil) w := testWorkerWith(st, qb, rec, nil)
w.recognizeOne(context.Background(), 1) w.recognizeOne(context.Background(), "1")
if st.downloads[1].State != store.StateReview { if st.downloads["1"].State != store.StateReview {
t.Fatalf("state = %q, want review", st.downloads[1].State) t.Fatalf("state = %q, want review", st.downloads["1"].State)
} }
cur, _ := st.GetCurrentRecognition(context.Background(), 1) cur, _ := st.GetCurrentRecognition(context.Background(), "1")
if cur == nil || len(cur.ReasonList()) == 0 { if cur == nil || len(cur.ReasonList()) == 0 {
t.Fatal("expected review with reason") t.Fatal("expected review with reason")
} }
@@ -627,82 +663,82 @@ func TestRecognizeOne_SignalsErrorToReview(t *testing.T) {
func TestRefine_AddsHintAndRerecognizes(t *testing.T) { func TestRefine_AddsHintAndRerecognizes(t *testing.T) {
st := newMemStore() st := newMemStore()
d := completedDownload(1) d := completedDownload("1")
d.State = store.StateReview d.State = store.StateReview
st.put(d) st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}} qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Refine(context.Background(), 1, "это второй сезон"); err != nil { if err := w.Refine(context.Background(), "1", "это второй сезон"); err != nil {
t.Fatalf("Refine: %v", err) t.Fatalf("Refine: %v", err)
} }
if st.downloads[1].State != store.StateRecognizing { if st.downloads["1"].State != store.StateRecognizing {
t.Errorf("state = %q, want recognizing", st.downloads[1].State) t.Errorf("state = %q, want recognizing", st.downloads["1"].State)
} }
if h := st.hints[1]; len(h) != 1 || h[0] != "это второй сезон" { if h := st.hints["1"]; len(h) != 1 || h[0] != "это второй сезон" {
t.Errorf("hints = %v", h) t.Errorf("hints = %v", h)
} }
if err := w.Refine(context.Background(), 1, " "); err == nil { if err := w.Refine(context.Background(), "1", " "); err == nil {
t.Error("empty hint must be rejected") t.Error("empty hint must be rejected")
} }
} }
func TestSetType(t *testing.T) { func TestSetType(t *testing.T) {
st := newMemStore() st := newMemStore()
d := completedDownload(1) d := completedDownload("1")
d.State = store.StateReview d.State = store.StateReview
st.put(d) st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}} qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.SetType(context.Background(), 1, "series"); err != nil { if err := w.SetType(context.Background(), "1", "series"); err != nil {
t.Fatalf("SetType: %v", err) t.Fatalf("SetType: %v", err)
} }
if st.overrides[1][ovrMediaType] != "series" { if st.overrides["1"][ovrMediaType] != "series" {
t.Errorf("override = %v", st.overrides[1]) t.Errorf("override = %v", st.overrides["1"])
} }
if st.downloads[1].State != store.StateRecognizing { if st.downloads["1"].State != store.StateRecognizing {
t.Errorf("state = %q, want recognizing", st.downloads[1].State) t.Errorf("state = %q, want recognizing", st.downloads["1"].State)
} }
if err := w.SetType(context.Background(), 1, "cartoon"); err == nil { if err := w.SetType(context.Background(), "1", "cartoon"); err == nil {
t.Error("invalid type must be rejected") t.Error("invalid type must be rejected")
} }
} }
func TestIgnoreFile(t *testing.T) { func TestIgnoreFile(t *testing.T) {
st := newMemStore() st := newMemStore()
d := completedDownload(1) d := completedDownload("1")
d.State = store.StateReview d.State = store.StateReview
st.put(d) st.put(d)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
if err := w.IgnoreFile(context.Background(), 1, "Show/sample.mkv"); err != nil { if err := w.IgnoreFile(context.Background(), "1", "Show/sample.mkv"); err != nil {
t.Fatalf("IgnoreFile: %v", err) t.Fatalf("IgnoreFile: %v", err)
} }
if err := w.IgnoreFile(context.Background(), 1, "Show/sample.mkv"); err != nil { // повтор не дублирует if err := w.IgnoreFile(context.Background(), "1", "Show/sample.mkv"); err != nil { // повтор не дублирует
t.Fatalf("IgnoreFile repeat: %v", err) t.Fatalf("IgnoreFile repeat: %v", err)
} }
ignored := parseIgnored(st.overrides[1][ovrIgnoredFiles]) ignored := parseIgnored(st.overrides["1"][ovrIgnoredFiles])
if len(ignored) != 1 || ignored[0] != "Show/sample.mkv" { if len(ignored) != 1 || ignored[0] != "Show/sample.mkv" {
t.Errorf("ignored = %v", ignored) t.Errorf("ignored = %v", ignored)
} }
if st.downloads[1].State != store.StateReview { if st.downloads["1"].State != store.StateReview {
t.Errorf("ignore must keep review, got %q", st.downloads[1].State) t.Errorf("ignore must keep review, got %q", st.downloads["1"].State)
} }
} }
func TestDefer(t *testing.T) { func TestDefer(t *testing.T) {
st := newMemStore() st := newMemStore()
d := completedDownload(1) d := completedDownload("1")
d.State = store.StateReview d.State = store.StateReview
st.put(d) st.put(d)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
if err := w.Defer(context.Background(), 1); err != nil { if err := w.Defer(context.Background(), "1"); err != nil {
t.Fatalf("Defer: %v", err) t.Fatalf("Defer: %v", err)
} }
if st.downloads[1].State != store.StateDeferred { if st.downloads["1"].State != store.StateDeferred {
t.Errorf("state = %q, want deferred", st.downloads[1].State) t.Errorf("state = %q, want deferred", st.downloads["1"].State)
} }
} }
@@ -739,12 +775,12 @@ func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
} }
st := newMemStore() st := newMemStore()
d := completedDownload(1) d := completedDownload("1")
d.State = store.StateReview d.State = store.StateReview
st.put(d) st.put(d)
planJSON, _ := json.Marshal(plan) planJSON, _ := json.Marshal(plan)
st.recs = append(st.recs, &store.Recognition{ st.recs = append(st.recs, &store.Recognition{
ID: 1, DownloadID: 1, IsCurrent: true, Plan: store.NullString(string(planJSON)), ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
}) })
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: downloads, Category: "jellybit"}}} qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: downloads, Category: "jellybit"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, lay) w := testWorkerWith(st, qb, &fakeRecognizer{}, lay)
@@ -755,11 +791,11 @@ func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
func TestApply_LinksAndDone(t *testing.T) { func TestApply_LinksAndDone(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan) f := newApplyFixture(t, seriesResult().Plan)
if err := f.w.Apply(context.Background(), 1); err != nil { if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
if f.st.downloads[1].State != store.StateDone { if f.st.downloads["1"].State != store.StateDone {
t.Fatalf("state = %q, want done", f.st.downloads[1].State) t.Fatalf("state = %q, want done", f.st.downloads["1"].State)
} }
if len(f.st.links) != 2 { if len(f.st.links) != 2 {
t.Fatalf("file_links = %d, want 2", len(f.st.links)) t.Fatalf("file_links = %d, want 2", len(f.st.links))
@@ -780,9 +816,9 @@ func TestApply_IgnoredFileSkipped(t *testing.T) {
Src: "Show/sample.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e, Src: "Show/sample.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e,
}) })
f := newApplyFixture(t, plan) f := newApplyFixture(t, plan)
_ = f.st.SetOverride(context.Background(), 1, ovrIgnoredFiles, `["Show/sample.mkv"]`) _ = f.st.SetOverride(context.Background(), "1", ovrIgnoredFiles, `["Show/sample.mkv"]`)
if err := f.w.Apply(context.Background(), 1); err != nil { if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
if len(f.st.links) != 2 { // sample пропущен if len(f.st.links) != 2 { // sample пропущен
@@ -798,12 +834,12 @@ func TestApply_CollisionStaysReview(t *testing.T) {
_ = os.MkdirAll(filepath.Dir(dst), 0o755) _ = os.MkdirAll(filepath.Dir(dst), 0o755)
_ = os.WriteFile(dst, []byte("foreign"), 0o644) _ = os.WriteFile(dst, []byte("foreign"), 0o644)
err := f.w.Apply(context.Background(), 1) err := f.w.Apply(context.Background(), "1")
if err == nil { if err == nil {
t.Fatal("want collision error") t.Fatal("want collision error")
} }
if f.st.downloads[1].State != store.StateReview { if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, want review after collision", f.st.downloads[1].State) t.Errorf("state = %q, want review after collision", f.st.downloads["1"].State)
} }
b, _ := os.ReadFile(dst) b, _ := os.ReadFile(dst)
if string(b) != "foreign" { if string(b) != "foreign" {
@@ -817,20 +853,20 @@ func TestApply_SupersedesForeignOwnerOfPath(t *testing.T) {
plan := seriesResult().Plan plan := seriesResult().Plan
f := newApplyFixture(t, plan) f := newApplyFixture(t, plan)
e01 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv") e01 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
f.st.put(completedDownload(2)) f.st.put(completedDownload("2"))
f.st.links = append(f.st.links, store.FileLink{ f.st.links = append(f.st.links, store.FileLink{
DownloadID: 2, ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01, DownloadID: "2", ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
Kind: "video", Status: "linked", Kind: "video", Status: "linked",
}) })
if err := f.w.Apply(context.Background(), 1); err != nil { if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
// Чужая ссылка на перехваченный путь — superseded. // Чужая ссылка на перехваченный путь — superseded.
var foreign *store.FileLink var foreign *store.FileLink
for i := range f.st.links { for i := range f.st.links {
if f.st.links[i].DownloadID == 2 { if f.st.links[i].DownloadID == "2" {
foreign = &f.st.links[i] foreign = &f.st.links[i]
} }
} }
@@ -839,12 +875,12 @@ func TestApply_SupersedesForeignOwnerOfPath(t *testing.T) {
} }
// Свои ссылки (id=1) не тронуты — download_id != self. // Свои ссылки (id=1) не тронуты — download_id != self.
for _, l := range f.st.links { for _, l := range f.st.links {
if l.DownloadID == 1 && !isLaidOut(l.Status) { if l.DownloadID == "1" && !isLaidOut(l.Status) {
t.Errorf("своя ссылка %q стала %q, ожидали разложенную", l.DstPath, l.Status) t.Errorf("своя ссылка %q стала %q, ожидали разложенную", l.DstPath, l.Status)
} }
} }
// Прежняя загрузка больше не владеет путём → цель отсутствует. // Прежняя загрузка больше не владеет путём → цель отсутствует.
present, err := f.w.targetPresent(context.Background(), 2) present, err := f.w.targetPresent(context.Background(), "2")
if err != nil { if err != nil {
t.Fatalf("targetPresent: %v", err) t.Fatalf("targetPresent: %v", err)
} }
@@ -861,17 +897,17 @@ func TestApply_CollisionKeepsForeignOwner(t *testing.T) {
e01 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv") e01 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
_ = os.MkdirAll(filepath.Dir(e01), 0o755) _ = os.MkdirAll(filepath.Dir(e01), 0o755)
_ = os.WriteFile(e01, []byte("foreign"), 0o644) _ = os.WriteFile(e01, []byte("foreign"), 0o644)
f.st.put(completedDownload(2)) f.st.put(completedDownload("2"))
f.st.links = append(f.st.links, store.FileLink{ f.st.links = append(f.st.links, store.FileLink{
DownloadID: 2, ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01, DownloadID: "2", ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
Kind: "video", Status: "linked", Kind: "video", Status: "linked",
}) })
if err := f.w.Apply(context.Background(), 1); err == nil { if err := f.w.Apply(context.Background(), "1"); err == nil {
t.Fatal("want collision error") t.Fatal("want collision error")
} }
for _, l := range f.st.links { for _, l := range f.st.links {
if l.DownloadID == 2 && l.Status != "linked" { if l.DownloadID == "2" && l.Status != "linked" {
t.Errorf("чужая ссылка стала %q при коллизии, владение не должно отбираться", l.Status) t.Errorf("чужая ссылка стала %q при коллизии, владение не должно отбираться", l.Status)
} }
} }
@@ -880,7 +916,7 @@ func TestApply_CollisionKeepsForeignOwner(t *testing.T) {
func TestUndo_RevertsLinks(t *testing.T) { func TestUndo_RevertsLinks(t *testing.T) {
plan := seriesResult().Plan plan := seriesResult().Plan
f := newApplyFixture(t, plan) f := newApplyFixture(t, plan)
if err := f.w.Apply(context.Background(), 1); err != nil { if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv") dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
@@ -888,11 +924,11 @@ func TestUndo_RevertsLinks(t *testing.T) {
t.Fatalf("precondition: link must exist: %v", err) t.Fatalf("precondition: link must exist: %v", err)
} }
if err := f.w.Undo(context.Background(), 1); err != nil { if err := f.w.Undo(context.Background(), "1"); err != nil {
t.Fatalf("Undo: %v", err) t.Fatalf("Undo: %v", err)
} }
if f.st.downloads[1].State != store.StateReverted { if f.st.downloads["1"].State != store.StateReverted {
t.Errorf("state = %q, want reverted", f.st.downloads[1].State) t.Errorf("state = %q, want reverted", f.st.downloads["1"].State)
} }
if _, err := os.Stat(dst); !os.IsNotExist(err) { if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Errorf("link must be removed: %v", err) t.Errorf("link must be removed: %v", err)
@@ -909,9 +945,9 @@ func TestUndo_RevertsLinks(t *testing.T) {
func TestReviewData(t *testing.T) { func TestReviewData(t *testing.T) {
plan := seriesResult().Plan plan := seriesResult().Plan
f := newApplyFixture(t, plan) f := newApplyFixture(t, plan)
_ = f.st.AddHint(context.Background(), 1, "подсказка") _ = f.st.AddHint(context.Background(), "1", "подсказка")
rd, err := f.w.ReviewData(context.Background(), 1) rd, err := f.w.ReviewData(context.Background(), "1")
if err != nil { if err != nil {
t.Fatalf("ReviewData: %v", err) t.Fatalf("ReviewData: %v", err)
} }
@@ -964,7 +1000,7 @@ func TestRecognizeOne_AutoApplies(t *testing.T) {
lay, _ := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}, nil) lay, _ := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}, nil)
st := newMemStore() st := newMemStore()
st.put(completedDownload(1)) st.put(completedDownload("1"))
qb := &fakeQbt{ qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: downloads, Category: "jellybit"}}, torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: downloads, Category: "jellybit"}},
files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}, {Name: "Show/e2.mkv", Size: 1}}, files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}, {Name: "Show/e2.mkv", Size: 1}},
@@ -976,10 +1012,10 @@ func TestRecognizeOne_AutoApplies(t *testing.T) {
}} }}
w := testWorkerWith(st, qb, rec, lay) w := testWorkerWith(st, qb, rec, lay)
w.recognizeOne(context.Background(), 1) w.recognizeOne(context.Background(), "1")
if st.downloads[1].State != store.StateDone { if st.downloads["1"].State != store.StateDone {
t.Fatalf("state = %q, want done (auto)", st.downloads[1].State) t.Fatalf("state = %q, want done (auto)", st.downloads["1"].State)
} }
// Provider-тег попал в имя папки. // Provider-тег попал в имя папки.
want := filepath.Join(series, "Show (2006) [tmdbid-42]", "Season 02", "Show (2006) S02E01.mkv") want := filepath.Join(series, "Show (2006) [tmdbid-42]", "Season 02", "Show (2006) S02E01.mkv")
@@ -996,7 +1032,7 @@ func TestApply_UsesProviderTag(t *testing.T) {
f.st.recs[0].Provider = store.NullString("tmdb") f.st.recs[0].Provider = store.NullString("tmdb")
f.st.recs[0].ProviderID = store.NullString("603") f.st.recs[0].ProviderID = store.NullString("603")
if err := f.w.Apply(context.Background(), 1); err != nil { if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
want := filepath.Join(f.series, "Show (2006) [tmdbid-603]", "Season 02", "Show (2006) S02E01.mkv") want := filepath.Join(f.series, "Show (2006) [tmdbid-603]", "Season 02", "Show (2006) S02E01.mkv")
@@ -1026,15 +1062,15 @@ func TestProviderTag(t *testing.T) {
func reviewWithCandidate(t *testing.T, cand store.MetadataCandidate) (*Worker, *memStore) { func reviewWithCandidate(t *testing.T, cand store.MetadataCandidate) (*Worker, *memStore) {
t.Helper() t.Helper()
st := newMemStore() st := newMemStore()
d := completedDownload(1) d := completedDownload("1")
d.State = store.StateReview d.State = store.StateReview
st.put(d) st.put(d)
planJSON, _ := json.Marshal(recognize.Plan{Type: recognize.MediaSeries, Title: "Догадка", Year: 2000}) planJSON, _ := json.Marshal(recognize.Plan{Type: recognize.MediaSeries, Title: "Догадка", Year: 2000})
st.recs = append(st.recs, &store.Recognition{ st.recs = append(st.recs, &store.Recognition{
ID: 1, DownloadID: 1, IsCurrent: true, Plan: store.NullString(string(planJSON)), ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
Provider: store.NullString("none"), Provider: store.NullString("none"),
}) })
cand.RecognitionID = 1 cand.RecognitionID = "1"
_ = st.CreateCandidates(context.Background(), []store.MetadataCandidate{cand}) _ = st.CreateCandidates(context.Background(), []store.MetadataCandidate{cand})
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
return w, st return w, st
@@ -1042,7 +1078,7 @@ func reviewWithCandidate(t *testing.T, cand store.MetadataCandidate) (*Worker, *
func TestRecognizeOne_PersistsCandidates(t *testing.T) { func TestRecognizeOne_PersistsCandidates(t *testing.T) {
st := newMemStore() st := newMemStore()
st.put(completedDownload(1)) st.put(completedDownload("1"))
qb := &fakeQbt{ qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}, torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
files: []qbt.File{{Name: "e1.mkv", Size: 1}}, files: []qbt.File{{Name: "e1.mkv", Size: 1}},
@@ -1054,7 +1090,7 @@ func TestRecognizeOne_PersistsCandidates(t *testing.T) {
} }
w := testWorkerWith(st, qb, &fakeRecognizer{result: res}, nil) w := testWorkerWith(st, qb, &fakeRecognizer{result: res}, nil)
w.recognizeOne(context.Background(), 1) w.recognizeOne(context.Background(), "1")
if len(st.candidates) != 2 { if len(st.candidates) != 2 {
t.Fatalf("candidates = %d, want 2", len(st.candidates)) t.Fatalf("candidates = %d, want 2", len(st.candidates))
@@ -1080,10 +1116,10 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
}) })
candID := st.candidates[0].ID candID := st.candidates[0].ID
if err := w.ChooseCandidate(context.Background(), 1, candID); err != nil { if err := w.ChooseCandidate(context.Background(), "1", candID); err != nil {
t.Fatalf("ChooseCandidate: %v", err) t.Fatalf("ChooseCandidate: %v", err)
} }
ov := st.overrides[1] ov := st.overrides["1"]
if ov[ovrProvider] != "tvdb" || ov[ovrProviderID] != "269613" || if ov[ovrProvider] != "tvdb" || ov[ovrProviderID] != "269613" ||
ov[ovrTitle] != "Fargo" || ov[ovrYear] != "2014" { ov[ovrTitle] != "Fargo" || ov[ovrYear] != "2014" {
t.Errorf("overrides = %v", ov) t.Errorf("overrides = %v", ov)
@@ -1092,7 +1128,7 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
t.Error("кандидат не помечен выбранным") t.Error("кандидат не помечен выбранным")
} }
// Эффективный план берёт каноническое имя/год и тег [tvdbid-...]. // Эффективный план берёт каноническое имя/год и тег [tvdbid-...].
plan, tag, err := w.effectivePlan(context.Background(), 1) plan, tag, err := w.effectivePlan(context.Background(), "1")
if err != nil { if err != nil {
t.Fatalf("effectivePlan: %v", err) t.Fatalf("effectivePlan: %v", err)
} }
@@ -1106,38 +1142,38 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
func TestChooseCandidate_RejectsForeign(t *testing.T) { func TestChooseCandidate_RejectsForeign(t *testing.T) {
w, _ := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"}) w, _ := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
if err := w.ChooseCandidate(context.Background(), 1, 999); err == nil { if err := w.ChooseCandidate(context.Background(), "1", "999"); err == nil {
t.Error("чужой кандидат должен отклоняться") t.Error("чужой кандидат должен отклоняться")
} }
} }
func TestSetProviderID(t *testing.T) { func TestSetProviderID(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"}) w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
if err := w.SetProviderID(context.Background(), 1, "TMDB", " 603 "); err != nil { if err := w.SetProviderID(context.Background(), "1", "TMDB", " 603 "); err != nil {
t.Fatalf("SetProviderID: %v", err) t.Fatalf("SetProviderID: %v", err)
} }
if st.overrides[1][ovrProvider] != "tmdb" || st.overrides[1][ovrProviderID] != "603" { if st.overrides["1"][ovrProvider] != "tmdb" || st.overrides["1"][ovrProviderID] != "603" {
t.Errorf("overrides = %v", st.overrides[1]) t.Errorf("overrides = %v", st.overrides["1"])
} }
if err := w.SetProviderID(context.Background(), 1, "kinopoisk", "1"); err == nil { if err := w.SetProviderID(context.Background(), "1", "kinopoisk", "1"); err == nil {
t.Error("недопустимый провайдер должен отклоняться") t.Error("недопустимый провайдер должен отклоняться")
} }
if err := w.SetProviderID(context.Background(), 1, "tmdb", ""); err == nil { if err := w.SetProviderID(context.Background(), "1", "tmdb", ""); err == nil {
t.Error("пустой id должен отклоняться") t.Error("пустой id должен отклоняться")
} }
} }
func TestClearProvider(t *testing.T) { func TestClearProvider(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"}) w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
_ = st.SetOverride(context.Background(), 1, ovrProvider, "tvdb") _ = st.SetOverride(context.Background(), "1", ovrProvider, "tvdb")
if err := w.ClearProvider(context.Background(), 1); err != nil { if err := w.ClearProvider(context.Background(), "1"); err != nil {
t.Fatalf("ClearProvider: %v", err) t.Fatalf("ClearProvider: %v", err)
} }
if st.overrides[1][ovrProvider] != "none" { if st.overrides["1"][ovrProvider] != "none" {
t.Errorf("provider override = %q, want none", st.overrides[1][ovrProvider]) t.Errorf("provider override = %q, want none", st.overrides["1"][ovrProvider])
} }
// «Без базы» → пустой тег. // «Без базы» → пустой тег.
_, tag, _ := w.effectivePlan(context.Background(), 1) _, tag, _ := w.effectivePlan(context.Background(), "1")
if tag != "" { if tag != "" {
t.Errorf("tag = %q, want empty", tag) t.Errorf("tag = %q, want empty", tag)
} }
@@ -1148,10 +1184,10 @@ func TestReviewData_IncludesCandidates(t *testing.T) {
Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"), Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
}) })
candID := st.candidates[0].ID candID := st.candidates[0].ID
if err := w.ChooseCandidate(context.Background(), 1, candID); err != nil { if err := w.ChooseCandidate(context.Background(), "1", candID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
rd, err := w.ReviewData(context.Background(), 1) rd, err := w.ReviewData(context.Background(), "1")
if err != nil { if err != nil {
t.Fatalf("ReviewData: %v", err) t.Fatalf("ReviewData: %v", err)
} }
@@ -1169,7 +1205,7 @@ func TestReviewData_IncludesCandidates(t *testing.T) {
func TestToStoreCandidates_URL(t *testing.T) { func TestToStoreCandidates_URL(t *testing.T) {
// Кандидат с URL: URL должен быть проброшен как непустой NullString. // Кандидат с URL: URL должен быть проброшен как непустой NullString.
// Кандидат без URL: URL должен быть пустым NullString (Valid=false → NULL). // Кандидат без URL: URL должен быть пустым NullString (Valid=false → NULL).
candURL := toStoreCandidates(1, []metadata.Candidate{ candURL := toStoreCandidates("1", []metadata.Candidate{
{Provider: "tmdb", ID: "603", Title: "With URL", URL: "https://www.themoviedb.org/movie/603"}, {Provider: "tmdb", ID: "603", Title: "With URL", URL: "https://www.themoviedb.org/movie/603"},
{Provider: "tvdb", ID: "1", Title: "Without URL", URL: ""}, {Provider: "tvdb", ID: "1", Title: "Without URL", URL: ""},
}) })
+93 -42
View File
@@ -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. Вызывается
@@ -196,7 +200,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 +225,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 +250,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 +330,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 +357,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 +403,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 +492,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 +501,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 +518,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 +527,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 +547,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
} }
+165 -47
View File
@@ -2,6 +2,7 @@ package worker
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
@@ -20,12 +21,12 @@ const (
) )
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
+161
View File
@@ -0,0 +1,161 @@
# identity Specification
## Purpose
Как система идентифицирует сущности домена: ULID-ключи (канонический
lowercase-вид, нормализация и валидация на входных границах), множество
инфохэшей загрузки (`download_infohash`), инвариант «не более одной
активной загрузки на infohash» (дедупликация приёма, атомарный возврат в
активное состояние), корреляция сущностей в логах по id.
## 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`
+13 -13
View File
@@ -17,10 +17,10 @@ qBittorrent. Capability описывает периодическую и при
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых `worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
выводить состояние задачи из двух независимых признаков: присутствия выводить состояние задачи из двух независимых признаков: присутствия
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и **источника** (раздача, совпавшая с **любым из известных хешей** загрузки в
присутствия **цели** (см. требование о владении целевым путём: существуют все `download_infohash`, в выдаче qBittorrent) и присутствия **цели** (см.
ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой требование о владении целевым путём: существуют все ссылки последнего батча
загрузке). со статусом раскладки, всё ещё принадлежащие этой загрузке).
Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`, Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`,
`target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT — `target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT —
@@ -70,14 +70,14 @@ qBittorrent. Capability описывает периодическую и при
`downloading`). Восстановление SHALL опираться на фактическое состояние `downloading`). Восстановление SHALL опираться на фактическое состояние
торрента в qBittorrent, а не на время с момента создания записи. торрента в qBittorrent, а не на время с момента создания записи.
При возврате в любое нетерминальное состояние (`downloading` или После возврата в любое нетерминальное состояние (`downloading` или
`completed`) система SHALL восстанавливать идемпотентность задачи `completed`) повторный приём того же infohash SHALL снова дедуплицироваться
(`idempotency_key`), чтобы повторный приём того же infohash снова на эту задачу: активность задачи выводится только из её `state`, отдельный
дедуплицировался на эту задачу. Если за время простоя в `failed`/`stuck` тем восстанавливаемый ключ идемпотентности отсутствует. Если за время простоя в
же infohash уже завладела другая активная задача (ключ снимается при падении и `failed`/`stuck` тем же infohash (любым из хешей задачи) уже завладела
мог быть перехвачен новым приёмом), система SHALL NOT воскрешать упавшую другая активная задача (новый приём, пока эта лежала упавшей), система
задачу и SHALL оставить её в `failed`/`stuck`, сохраняя инвариант «не более SHALL NOT воскрешать упавшую задачу и SHALL оставить её в `failed`/`stuck`,
одной активной задачи на infohash». сохраняя инвариант «не более одной активной задачи на infohash».
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не `magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом
@@ -91,7 +91,7 @@ qBittorrent. Capability описывает периодическую и при
в qBittorrent уже получил метаданные и качается (`downloading`) в qBittorrent уже получил метаданные и качается (`downloading`)
- **WHEN** срабатывает фоновая сверка - **WHEN** срабатывает фоновая сверка
- **THEN** задача возвращается в `downloading` - **THEN** задача возвращается в `downloading`
- **AND** её `idempotency_key` восстанавливается - **AND** повторный приём того же infohash снова дедуплицируется на неё
#### Scenario: Торрент уже завершился, пока задача была в failed #### Scenario: Торрент уже завершился, пока задача была в failed
+1 -1
View File
@@ -94,7 +94,7 @@
<div class="section-head"><h2>Информация о торренте</h2><span class="hint">сырой источник и infohash</span></div> <div class="section-head"><h2>Информация о торренте</h2><span class="hint">сырой источник и infohash</span></div>
<dl class="kv"> <dl class="kv">
<dt>Тип источника</dt><dd>{{.SourceType}}</dd> <dt>Тип источника</dt><dd>{{.SourceType}}</dd>
{{if .Infohash}}<dt>infohash</dt><dd class="mono">{{.Infohash}}</dd>{{end}} {{range .Infohashes}}<dt>infohash</dt><dd class="mono">{{.}}</dd>{{end}}
</dl> </dl>
{{if .SourceFull}} {{if .SourceFull}}
<div class="src-block"> <div class="src-block">