Идентичность на 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:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 + нормализация на границах,
|
||||||
|
естественные ключи у деталей.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4
|
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4
|
||||||
|
github.com/oklog/ulid/v2 v2.1.1
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3
|
github.com/pelletier/go-toml/v2 v2.2.3
|
||||||
github.com/pressly/goose/v3 v3.22.1
|
github.com/pressly/goose/v3 v3.22.1
|
||||||
modernc.org/sqlite v1.34.1
|
modernc.org/sqlite v1.34.1
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4 h1
|
|||||||
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4/go.mod h1:H66QhXPJpUSdWschhL6u//v3ge96/qMnQ9mWp3efbxA=
|
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4/go.mod h1:H66QhXPJpUSdWschhL6u//v3ge96/qMnQ9mWp3efbxA=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||||
|
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||||
|
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
|||||||
@@ -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
@@ -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()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Package ident — единственная точка генерации и разбора идентификаторов
|
||||||
|
// домена. Идентификатор — ULID (26 символов Crockford base32), канонический
|
||||||
|
// вид — lowercase; на входных границах (URL, формы) значение прогоняется
|
||||||
|
// через Parse до обращения к хранилищу, потому что сравнение строк в SQLite
|
||||||
|
// побайтовое.
|
||||||
|
package ident
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// entropy — monotonic-источник: при равной миллисекунде timestamp'а энтропия
|
||||||
|
// инкрементируется, так что id, выданные подряд, сохраняют порядок выдачи.
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
entropy = ulid.Monotonic(rand.Reader, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewID возвращает новый идентификатор (текущее время).
|
||||||
|
func NewID() string {
|
||||||
|
return NewIDAt(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIDAt возвращает идентификатор с timestamp-частью из t. Используется
|
||||||
|
// миграциями для бэкфилла: сортировка id сохраняет историческую хронологию
|
||||||
|
// (created_at имеет секундное разрешение — равные метки упорядочивает
|
||||||
|
// monotonic-энтропия в порядке вызовов).
|
||||||
|
func NewIDAt(t time.Time) string {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
id := ulid.MustNew(ulid.Timestamp(t.UTC()), entropy)
|
||||||
|
return strings.ToLower(id.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse валидирует внешний идентификатор и нормализует его к каноническому
|
||||||
|
// lowercase-виду. Регистр входа не важен (base32 ULID case-insensitive).
|
||||||
|
func Parse(s string) (string, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
u, err := ulid.ParseStrict(strings.ToUpper(s))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ident: parse %q: %w", s, err)
|
||||||
|
}
|
||||||
|
return strings.ToLower(u.String()), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package ident
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewIDLowercaseAndValid(t *testing.T) {
|
||||||
|
id := NewID()
|
||||||
|
if len(id) != 26 {
|
||||||
|
t.Fatalf("len(%q) = %d, want 26", id, len(id))
|
||||||
|
}
|
||||||
|
if id != strings.ToLower(id) {
|
||||||
|
t.Fatalf("id %q is not lowercase", id)
|
||||||
|
}
|
||||||
|
if _, err := Parse(id); err != nil {
|
||||||
|
t.Fatalf("Parse(NewID()) failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewIDSortedByIssueOrder(t *testing.T) {
|
||||||
|
prev := NewID()
|
||||||
|
for range 100 {
|
||||||
|
next := NewID()
|
||||||
|
if next <= prev {
|
||||||
|
t.Fatalf("ids out of order: %q then %q", prev, next)
|
||||||
|
}
|
||||||
|
prev = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewIDAtEqualTimestampsKeepOrder(t *testing.T) {
|
||||||
|
ts := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
|
||||||
|
prev := NewIDAt(ts)
|
||||||
|
for range 100 {
|
||||||
|
next := NewIDAt(ts) // одна и та же миллисекунда → monotonic-энтропия
|
||||||
|
if next <= prev {
|
||||||
|
t.Fatalf("ids out of order at equal timestamp: %q then %q", prev, next)
|
||||||
|
}
|
||||||
|
prev = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewIDAtChronology(t *testing.T) {
|
||||||
|
older := NewIDAt(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
newer := NewIDAt(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
if older >= newer {
|
||||||
|
t.Fatalf("chronology broken: %q >= %q", older, newer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNormalizesCase(t *testing.T) {
|
||||||
|
id := NewID()
|
||||||
|
got, err := Parse(strings.ToUpper(id))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse(upper) failed: %v", err)
|
||||||
|
}
|
||||||
|
if got != id {
|
||||||
|
t.Fatalf("Parse(upper) = %q, want %q", got, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRejectsGarbage(t *testing.T) {
|
||||||
|
for _, bad := range []string{"", "abc", "abc!!!", "123", strings.Repeat("z", 26), strings.Repeat("a", 27)} {
|
||||||
|
if _, err := Parse(bad); err == nil {
|
||||||
|
t.Fatalf("Parse(%q) unexpectedly succeeded", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
-24
@@ -24,9 +24,17 @@ const errCodeQbitAdd = "qbit_add"
|
|||||||
|
|
||||||
// Store — нужная ingest часть хранилища.
|
// Store — нужная ingest часть хранилища.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
|
// FindActiveByInfohash — быстрый читающий дедуп-чек (до вызова LLM-namer);
|
||||||
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
|
// авторитетная проверка — внутри CreateDownloadIfNoActive.
|
||||||
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
|
FindActiveByInfohash(ctx context.Context, hashes ...string) (*store.Download, error)
|
||||||
|
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
|
||||||
|
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
|
||||||
|
// дедуп на активную задачу (недостающие хеши вызова метод доносит сам).
|
||||||
|
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error)
|
||||||
|
// AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на
|
||||||
|
// быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive.
|
||||||
|
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
||||||
|
SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// QBittorrent — нужная ingest часть клиента qBittorrent.
|
// QBittorrent — нужная ingest часть клиента qBittorrent.
|
||||||
@@ -58,7 +66,7 @@ type Service struct {
|
|||||||
// не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не
|
// не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не
|
||||||
// попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest
|
// попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest
|
||||||
// сам; closure избавляет ядро приёма от зависимости на пакет worker.
|
// сам; closure избавляет ядро приёма от зависимости на пакет worker.
|
||||||
notifyFailed func(downloadID int64)
|
notifyFailed func(downloadID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не
|
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не
|
||||||
@@ -68,7 +76,7 @@ func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *S
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetFailureNotifier подключает пинг о падении приёма (до начала работы).
|
// SetFailureNotifier подключает пинг о падении приёма (до начала работы).
|
||||||
func (s *Service) SetFailureNotifier(fn func(downloadID int64)) { s.notifyFailed = fn }
|
func (s *Service) SetFailureNotifier(fn func(downloadID string)) { s.notifyFailed = fn }
|
||||||
|
|
||||||
// Request — входной запрос приёма.
|
// Request — входной запрос приёма.
|
||||||
type Request struct {
|
type Request struct {
|
||||||
@@ -78,8 +86,8 @@ type Request struct {
|
|||||||
|
|
||||||
// Result — итог приёма.
|
// Result — итог приёма.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
DownloadID int64
|
DownloadID string
|
||||||
Infohash string
|
Infohashes []string // все хеши источника (гибридный magnet: v1 и v2, v1 первым)
|
||||||
State store.State
|
State store.State
|
||||||
Deduplicated bool // присоединились к уже активной задаче, нового добавления не было
|
Deduplicated bool // присоединились к уже активной задаче, нового добавления не было
|
||||||
}
|
}
|
||||||
@@ -100,16 +108,14 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
|||||||
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
|
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
|
||||||
ctx = logctx.With(ctx, log)
|
ctx = logctx.With(ctx, log)
|
||||||
|
|
||||||
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohash); err != nil {
|
// Быстрый дедуп-чек до дорогого LLM-namer; авторитетная (атомарная)
|
||||||
|
// проверка — внутри CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из
|
||||||
|
// хешей источника: гибридный magnet несёт и v1, и v2.
|
||||||
|
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohashes...); err != nil {
|
||||||
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
||||||
} else if existing != nil {
|
} else if existing != nil {
|
||||||
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||||||
return Result{
|
return s.attached(ctx, info, existing), nil
|
||||||
DownloadID: existing.ID,
|
|
||||||
Infohash: info.Infohash,
|
|
||||||
State: existing.State,
|
|
||||||
Deduplicated: true,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отображаемое имя для списка qBit — best-effort: не валит приём.
|
// Отображаемое имя для списка qBit — best-effort: не валит приём.
|
||||||
@@ -122,18 +128,30 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
d := &store.Download{
|
d := &store.Download{
|
||||||
SourceType: store.SourceMagnet,
|
SourceType: store.SourceMagnet,
|
||||||
SourceRef: source,
|
SourceRef: source,
|
||||||
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
|
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
|
||||||
Context: req.Context,
|
Context: req.Context,
|
||||||
Infohash: store.NullString(info.Infohash),
|
State: store.StateDownloading,
|
||||||
IdempotencyKey: store.NullString(info.Infohash),
|
|
||||||
State: store.StateDownloading,
|
|
||||||
}
|
}
|
||||||
id, err := s.store.CreateDownload(ctx, d)
|
// Все хеши из magnet (гибридный несёт v1 и v2); kind store выведет по длине.
|
||||||
|
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, info.Infohashes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("ingest: create download: %w", err)
|
return Result{}, fmt.Errorf("ingest: create download: %w", err)
|
||||||
}
|
}
|
||||||
|
if existing != nil {
|
||||||
|
// Гонка с параллельным приёмом/discover: активная задача появилась
|
||||||
|
// после быстрого чека — присоединяемся к ней (хеши донёс сам
|
||||||
|
// CreateDownloadIfNoActive).
|
||||||
|
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||||||
|
return Result{
|
||||||
|
DownloadID: existing.ID,
|
||||||
|
Infohashes: info.Infohashes,
|
||||||
|
State: existing.State,
|
||||||
|
Deduplicated: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
id := d.ID
|
||||||
log = log.With("download_id", id)
|
log = log.With("download_id", id)
|
||||||
ctx = logctx.With(ctx, log)
|
ctx = logctx.With(ctx, log)
|
||||||
|
|
||||||
@@ -156,14 +174,33 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
|||||||
// сами, чтобы приёмные провалы тоже доходили до автора.
|
// сами, чтобы приёмные провалы тоже доходили до автора.
|
||||||
go s.notifyFailed(id)
|
go s.notifyFailed(id)
|
||||||
}
|
}
|
||||||
return Result{DownloadID: id, Infohash: info.Infohash, State: store.StateFailed},
|
return Result{DownloadID: id, Infohashes: info.Infohashes, State: store.StateFailed},
|
||||||
fmt.Errorf("ingest: add to qbittorrent: %w", addErr)
|
fmt.Errorf("ingest: add to qbittorrent: %w", addErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("download accepted", "category", s.cfg.Category)
|
log.Info("download accepted", "category", s.cfg.Category)
|
||||||
return Result{
|
return Result{
|
||||||
DownloadID: id,
|
DownloadID: id,
|
||||||
Infohash: info.Infohash,
|
Infohashes: info.Infohashes,
|
||||||
State: store.StateDownloading,
|
State: store.StateDownloading,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attached — итог дедупа на быстром чеке: присоединились к уже активной
|
||||||
|
// задаче и доносим ей недостающие хеши источника (гибридный magnet мог
|
||||||
|
// принести хеш, которого задача ещё не знает; guarded-путь через
|
||||||
|
// CreateDownloadIfNoActive сюда не доходит). Донос — best-effort: конфликт
|
||||||
|
// хеша с другой активной задачей логируется, приём не валится.
|
||||||
|
func (s *Service) attached(ctx context.Context, info magnet.Info, existing *store.Download) Result {
|
||||||
|
if len(info.Infohashes) > len(existing.Infohashes) {
|
||||||
|
if err := s.store.AddInfohashes(ctx, existing.ID, info.Infohashes); err != nil {
|
||||||
|
logctx.FromOr(ctx, s.log).Warn("ingest top-up infohashes failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result{
|
||||||
|
DownloadID: existing.ID,
|
||||||
|
Infohashes: info.Infohashes,
|
||||||
|
State: existing.State,
|
||||||
|
Deduplicated: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
)
|
)
|
||||||
@@ -19,29 +20,39 @@ const sampleInfohash = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
|||||||
type fakeStore struct {
|
type fakeStore struct {
|
||||||
active *store.Download
|
active *store.Download
|
||||||
created []store.Download
|
created []store.Download
|
||||||
nextID int64
|
hashes [][]string
|
||||||
|
toppedUp []string
|
||||||
stateCalls []stateCall
|
stateCalls []stateCall
|
||||||
}
|
}
|
||||||
|
|
||||||
type stateCall struct {
|
type stateCall struct {
|
||||||
id int64
|
id string
|
||||||
state store.State
|
state store.State
|
||||||
code string
|
code string
|
||||||
msg string
|
msg string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ string) (*store.Download, error) {
|
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
|
||||||
return f.active, nil
|
return f.active, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
|
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
||||||
f.nextID++
|
if f.active != nil {
|
||||||
d.ID = f.nextID
|
return f.active, nil
|
||||||
|
}
|
||||||
|
d.ID = ident.NewID()
|
||||||
f.created = append(f.created, *d)
|
f.created = append(f.created, *d)
|
||||||
return f.nextID, nil
|
f.hashes = append(f.hashes, hashes)
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
|
func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string) error {
|
||||||
|
f.toppedUp = append(f.toppedUp, hashes...)
|
||||||
|
_ = id
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
|
||||||
f.stateCalls = append(f.stateCalls, stateCall{id, st, code, msg})
|
f.stateCalls = append(f.stateCalls, stateCall{id, st, code, msg})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -90,8 +101,8 @@ func TestIngestHappyPath(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Ingest: %v", err)
|
t.Fatalf("Ingest: %v", err)
|
||||||
}
|
}
|
||||||
if res.Infohash != sampleInfohash {
|
if len(res.Infohashes) != 1 || res.Infohashes[0] != sampleInfohash {
|
||||||
t.Errorf("infohash = %q", res.Infohash)
|
t.Errorf("infohashes = %v", res.Infohashes)
|
||||||
}
|
}
|
||||||
if res.State != store.StateDownloading || res.Deduplicated {
|
if res.State != store.StateDownloading || res.Deduplicated {
|
||||||
t.Errorf("res = %+v", res)
|
t.Errorf("res = %+v", res)
|
||||||
@@ -99,9 +110,12 @@ func TestIngestHappyPath(t *testing.T) {
|
|||||||
if len(fs.created) != 1 {
|
if len(fs.created) != 1 {
|
||||||
t.Fatalf("создано задач: %d, want 1", len(fs.created))
|
t.Fatalf("создано задач: %d, want 1", len(fs.created))
|
||||||
}
|
}
|
||||||
if got := fs.created[0]; got.Context != "Дюна 2" || got.Infohash.String != sampleInfohash {
|
if got := fs.created[0]; got.Context != "Дюна 2" {
|
||||||
t.Errorf("сохранённая задача: %+v", got)
|
t.Errorf("сохранённая задача: %+v", got)
|
||||||
}
|
}
|
||||||
|
if len(fs.hashes) != 1 || len(fs.hashes[0]) != 1 || fs.hashes[0][0] != sampleInfohash {
|
||||||
|
t.Errorf("хеши задачи: %v", fs.hashes)
|
||||||
|
}
|
||||||
if len(fq.added) != 1 {
|
if len(fq.added) != 1 {
|
||||||
t.Fatalf("вызовов qbt.Add: %d, want 1", len(fq.added))
|
t.Fatalf("вызовов qbt.Add: %d, want 1", len(fq.added))
|
||||||
}
|
}
|
||||||
@@ -149,15 +163,15 @@ func TestIngestEmptyNameOmitsRename(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestIdempotent(t *testing.T) {
|
func TestIngestIdempotent(t *testing.T) {
|
||||||
existing := &store.Download{ID: 7, State: store.StateDownloading}
|
existing := &store.Download{ID: "01hzzzexisting000000000000", State: store.StateDownloading}
|
||||||
fs := &fakeStore{active: existing}
|
fs := &fakeStore{active: existing}
|
||||||
fq := &fakeQbt{}
|
fq := &fakeQbt{}
|
||||||
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet})
|
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Ingest: %v", err)
|
t.Fatalf("Ingest: %v", err)
|
||||||
}
|
}
|
||||||
if !res.Deduplicated || res.DownloadID != 7 {
|
if !res.Deduplicated || res.DownloadID != existing.ID {
|
||||||
t.Errorf("ожидалось присоединение к задаче 7: %+v", res)
|
t.Errorf("ожидалось присоединение к существующей задаче: %+v", res)
|
||||||
}
|
}
|
||||||
if len(fs.created) != 0 {
|
if len(fs.created) != 0 {
|
||||||
t.Error("не должно создаваться новой задачи")
|
t.Error("не должно создаваться новой задачи")
|
||||||
@@ -167,6 +181,29 @@ func TestIngestIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Быстрый дедуп-путь доносит существующей задаче недостающие хеши
|
||||||
|
// гибридного magnet (иначе последующий приём по второму хешу создал бы
|
||||||
|
// вторую активную задачу).
|
||||||
|
func TestIngestDedupTopsUpHashes(t *testing.T) {
|
||||||
|
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
existing := &store.Download{
|
||||||
|
ID: "01hzzzexisting000000000000", State: store.StateDownloading,
|
||||||
|
Infohashes: []store.Infohash{{DownloadID: "01hzzzexisting000000000000", Infohash: sampleInfohash, Kind: store.HashV1}},
|
||||||
|
}
|
||||||
|
fs := &fakeStore{active: existing}
|
||||||
|
res, err := newService(fs, &fakeQbt{}).Ingest(context.Background(),
|
||||||
|
Request{Source: sampleMagnet + "&xt=urn:btmh:1220" + v2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ingest: %v", err)
|
||||||
|
}
|
||||||
|
if !res.Deduplicated {
|
||||||
|
t.Fatalf("ожидался дедуп: %+v", res)
|
||||||
|
}
|
||||||
|
if len(fs.toppedUp) != 2 {
|
||||||
|
t.Errorf("хеши не донесены существующей задаче: %v", fs.toppedUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIngestQbitErrorMarksFailed(t *testing.T) {
|
func TestIngestQbitErrorMarksFailed(t *testing.T) {
|
||||||
fs := &fakeStore{}
|
fs := &fakeStore{}
|
||||||
fq := &fakeQbt{err: errors.New("connection refused")}
|
fq := &fakeQbt{err: errors.New("connection refused")}
|
||||||
@@ -186,16 +223,16 @@ func TestIngestQbitErrorNotifies(t *testing.T) {
|
|||||||
fs := &fakeStore{}
|
fs := &fakeStore{}
|
||||||
fq := &fakeQbt{err: errors.New("connection refused")}
|
fq := &fakeQbt{err: errors.New("connection refused")}
|
||||||
svc := newService(fs, fq)
|
svc := newService(fs, fq)
|
||||||
got := make(chan int64, 1)
|
got := make(chan string, 1)
|
||||||
svc.SetFailureNotifier(func(id int64) { got <- id })
|
svc.SetFailureNotifier(func(id string) { got <- id })
|
||||||
|
|
||||||
if _, err := svc.Ingest(context.Background(), Request{Source: sampleMagnet}); err == nil {
|
if _, err := svc.Ingest(context.Background(), Request{Source: sampleMagnet}); err == nil {
|
||||||
t.Fatal("ожидалась ошибка")
|
t.Fatal("ожидалась ошибка")
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case id := <-got:
|
case id := <-got:
|
||||||
if id == 0 {
|
if id == "" {
|
||||||
t.Errorf("уведомление с нулевым id")
|
t.Errorf("уведомление с пустым id")
|
||||||
}
|
}
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
t.Fatal("уведомление о падении приёма не пришло")
|
t.Fatal("уведомление о падении приёма не пришло")
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import (
|
|||||||
|
|
||||||
// Info — разобранная magnet-ссылка.
|
// Info — разобранная magnet-ссылка.
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Infohash string // нормализованный нижний hex (40 для v1, 64 для v2)
|
Infohash string // первичный хеш: v1 приоритетно (нижний hex, 40 для v1, 64 для v2)
|
||||||
|
Infohashes []string // все хеши ссылки (гибридный magnet несёт btih и btmh); v1 раньше v2
|
||||||
DisplayName string // dn — человекочитаемое имя, если задано
|
DisplayName string // dn — человекочитаемое имя, если задано
|
||||||
Trackers []string // tr — трекеры
|
Trackers []string // tr — трекеры
|
||||||
}
|
}
|
||||||
@@ -49,16 +50,19 @@ func Parse(raw string) (Info, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
infohash := v1
|
var hashes []string
|
||||||
if infohash == "" {
|
for _, h := range []string{v1, v2} {
|
||||||
infohash = v2
|
if h != "" {
|
||||||
|
hashes = append(hashes, h)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if infohash == "" {
|
if len(hashes) == 0 {
|
||||||
return Info{}, fmt.Errorf("magnet without a usable infohash (xt)")
|
return Info{}, fmt.Errorf("magnet without a usable infohash (xt)")
|
||||||
}
|
}
|
||||||
|
|
||||||
return Info{
|
return Info{
|
||||||
Infohash: infohash,
|
Infohash: hashes[0],
|
||||||
|
Infohashes: hashes,
|
||||||
DisplayName: vals.Get("dn"),
|
DisplayName: vals.Get("dn"),
|
||||||
Trackers: vals["tr"],
|
Trackers: vals["tr"],
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -47,6 +47,24 @@ func TestParse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Гибридный magnet несёт оба хеша: Infohash — v1 (приоритет), Infohashes — оба.
|
||||||
|
func TestParseHybridKeepsBothHashes(t *testing.T) {
|
||||||
|
const (
|
||||||
|
v1 = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||||
|
v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
)
|
||||||
|
got, err := Parse("magnet:?xt=urn:btih:" + v1 + "&xt=urn:btmh:1220" + v2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
if got.Infohash != v1 {
|
||||||
|
t.Errorf("primary = %q, want v1", got.Infohash)
|
||||||
|
}
|
||||||
|
if len(got.Infohashes) != 2 || got.Infohashes[0] != v1 || got.Infohashes[1] != v2 {
|
||||||
|
t.Errorf("infohashes = %v, want [v1 v2]", got.Infohashes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseErrors(t *testing.T) {
|
func TestParseErrors(t *testing.T) {
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"https://example.com/file.torrent", // не magnet
|
"https://example.com/file.torrent", // не magnet
|
||||||
|
|||||||
+382
-78
@@ -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
@@ -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,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")
|
||||||
|
|||||||
@@ -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
@@ -11,25 +11,28 @@ import (
|
|||||||
func hashN(n int) string { return fmt.Sprintf("%040x", n) }
|
func hashN(n int) string { return fmt.Sprintf("%040x", n) }
|
||||||
|
|
||||||
// mkDownload заводит загрузку в заданном состоянии с display_name.
|
// mkDownload заводит загрузку в заданном состоянии с display_name.
|
||||||
func mkDownload(t *testing.T, st *Store, n int, state State, display string) int64 {
|
func mkDownload(t *testing.T, st *Store, n int, state State, display string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
d := newDownloading(hashN(n))
|
d := newDownloading()
|
||||||
d.DisplayName = display
|
d.DisplayName = display
|
||||||
id, err := st.CreateDownload(ctx, d)
|
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(n)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create #%d: %v", n, err)
|
t.Fatalf("create #%d: %v", n, err)
|
||||||
}
|
}
|
||||||
|
if existing != nil {
|
||||||
|
t.Fatalf("create #%d: unexpected dedup", n)
|
||||||
|
}
|
||||||
if state != StateDownloading {
|
if state != StateDownloading {
|
||||||
if err := st.SetDownloadState(ctx, id, state, "", ""); err != nil {
|
if err := st.SetDownloadState(ctx, d.ID, state, "", ""); err != nil {
|
||||||
t.Fatalf("set state #%d: %v", n, err)
|
t.Fatalf("set state #%d: %v", n, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return id
|
return d.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
func ids(ds []Download) []int64 {
|
func ids(ds []Download) []string {
|
||||||
out := make([]int64, len(ds))
|
out := make([]string, len(ds))
|
||||||
for i, d := range ds {
|
for i, d := range ds {
|
||||||
out[i] = d.ID
|
out[i] = d.ID
|
||||||
}
|
}
|
||||||
@@ -51,7 +54,7 @@ func TestListDownloadsPageFilterAndDeleted(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if total != 1 || len(page) != 1 || page[0].ID != review {
|
if total != 1 || len(page) != 1 || page[0].ID != review {
|
||||||
t.Fatalf("review group = %v (total %d), want [%d]", ids(page), total, review)
|
t.Fatalf("review group = %v (total %d), want [%s]", ids(page), total, review)
|
||||||
}
|
}
|
||||||
|
|
||||||
// all: deleted скрыт по умолчанию.
|
// all: deleted скрыт по умолчанию.
|
||||||
@@ -110,7 +113,7 @@ func TestListDownloadsPageSearch(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(page) != 1 || page[0].Infohash.String != hashN(2) {
|
if len(page) != 1 || page[0].PrimaryInfohash() != hashN(2) {
|
||||||
t.Fatalf("search by infohash = %v", ids(page))
|
t.Fatalf("search by infohash = %v", ids(page))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,7 +147,7 @@ func TestListDownloadsPageOrderAndPagination(t *testing.T) {
|
|||||||
// id3 фолбечит на created_at (~сейчас, 2026-07-01) — свежее, чем добавления
|
// id3 фолбечит на created_at (~сейчас, 2026-07-01) — свежее, чем добавления
|
||||||
// id1/id2 в июне → id3 первым; затем id2 (позже добавлен), затем id1.
|
// id1/id2 в июне → id3 первым; затем id2 (позже добавлен), затем id1.
|
||||||
got := ids(page)
|
got := ids(page)
|
||||||
want := []int64{id3, id2, id1}
|
want := []string{id3, id2, id1}
|
||||||
for i := range want {
|
for i := range want {
|
||||||
if got[i] != want[i] {
|
if got[i] != want[i] {
|
||||||
t.Fatalf("порядок = %v, want %v", got, want)
|
t.Fatalf("порядок = %v, want %v", got, want)
|
||||||
@@ -178,7 +181,7 @@ func TestListDownloadsPageTieBreakByID(t *testing.T) {
|
|||||||
|
|
||||||
// Одинаковое время добавления у всех → устойчивый порядок по id DESC.
|
// Одинаковое время добавления у всех → устойчивый порядок по id DESC.
|
||||||
same := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
same := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||||
var idList []int64
|
var idList []string
|
||||||
for i := 1; i <= 3; i++ {
|
for i := 1; i <= 3; i++ {
|
||||||
id := mkDownload(t, st, i, StateDownloading, fmt.Sprintf("d%d", i))
|
id := mkDownload(t, st, i, StateDownloading, fmt.Sprintf("d%d", i))
|
||||||
if err := st.SetSourceAddedAt(ctx, id, same); err != nil {
|
if err := st.SetSourceAddedAt(ctx, id, same); err != nil {
|
||||||
@@ -191,7 +194,7 @@ func TestListDownloadsPageTieBreakByID(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
got := ids(page)
|
got := ids(page)
|
||||||
want := []int64{idList[2], idList[1], idList[0]} // id DESC
|
want := []string{idList[2], idList[1], idList[0]} // id DESC
|
||||||
for i := range want {
|
for i := range want {
|
||||||
if got[i] != want[i] {
|
if got[i] != want[i] {
|
||||||
t.Fatalf("tie-break порядок = %v, want %v", got, want)
|
t.Fatalf("tie-break порядок = %v, want %v", got, want)
|
||||||
|
|||||||
@@ -0,0 +1,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()
|
||||||
|
}
|
||||||
@@ -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,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import (
|
|||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
_ "modernc.org/sqlite" // драйвер database/sql, имя "sqlite"
|
_ "modernc.org/sqlite" // драйвер database/sql, имя "sqlite"
|
||||||
|
|
||||||
|
// Go-миграции goose (регистрируются в init(); SQL-миграции — embed ниже).
|
||||||
|
_ "git.vakhrushev.me/av/jellybit/internal/store/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migrations/*.sql
|
//go:embed migrations/*.sql
|
||||||
@@ -29,8 +32,12 @@ func Open(dbPath string) (*Store, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// _txlock=immediate: каждая явная транзакция открывается как BEGIN
|
||||||
|
// IMMEDIATE — write-транзакция с самого начала. На этом держатся
|
||||||
|
// guarded-методы инварианта «одна активная загрузка на infohash»
|
||||||
|
// (check-then-write без гонок: SQLite сериализует писателей).
|
||||||
dsn := fmt.Sprintf(
|
dsn := fmt.Sprintf(
|
||||||
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
|
"file:%s?_txlock=immediate&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
|
||||||
dbPath,
|
dbPath,
|
||||||
)
|
)
|
||||||
db, err := sqlx.Connect("sqlite", dsn)
|
db, err := sqlx.Connect("sqlite", dsn)
|
||||||
|
|||||||
+31
-28
@@ -4,12 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
)
|
)
|
||||||
@@ -30,13 +30,13 @@ type Ingestor interface {
|
|||||||
|
|
||||||
// Reviewer — операции ревью (worker.Worker).
|
// Reviewer — операции ревью (worker.Worker).
|
||||||
type Reviewer interface {
|
type Reviewer interface {
|
||||||
ReviewData(ctx context.Context, id int64) (*worker.ReviewData, error)
|
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
|
||||||
Apply(ctx context.Context, id int64) error
|
Apply(ctx context.Context, id string) error
|
||||||
Refine(ctx context.Context, id int64, hint string) error
|
Refine(ctx context.Context, id string, hint string) error
|
||||||
SetType(ctx context.Context, id int64, mediaType string) error
|
SetType(ctx context.Context, id string, mediaType string) error
|
||||||
Defer(ctx context.Context, id int64) error
|
Defer(ctx context.Context, id string) error
|
||||||
Cancel(ctx context.Context, id int64) error
|
Cancel(ctx context.Context, id string) error
|
||||||
Retry(ctx context.Context, id int64) error
|
Retry(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config — параметры бота.
|
// Config — параметры бота.
|
||||||
@@ -54,8 +54,8 @@ type Bot struct {
|
|||||||
webBase string
|
webBase string
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
|
||||||
mu sync.Mutex // защищает pending
|
mu sync.Mutex // защищает pending
|
||||||
pending map[int64]int64 // chatID → downloadID, ждущий подсказку
|
pending map[int64]string // chatID → downloadID, ждущий подсказку
|
||||||
}
|
}
|
||||||
|
|
||||||
// New собирает бота поверх клиента Telegram.
|
// New собирает бота поверх клиента Telegram.
|
||||||
@@ -71,7 +71,7 @@ func New(client teleAPI, ing Ingestor, rev Reviewer, cfg Config, log *slog.Logge
|
|||||||
allowed: allowed,
|
allowed: allowed,
|
||||||
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
|
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
|
||||||
log: log,
|
log: log,
|
||||||
pending: map[int64]int64{},
|
pending: map[int64]string{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
|||||||
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
|
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+strconv.FormatInt(id, 10)+"…", nil)
|
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+id+"…", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,9 +151,9 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
|||||||
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := fmt.Sprintf("Принято #%d — %s.", res.DownloadID, res.State)
|
msg := fmt.Sprintf("Принято #%s — %s.", res.DownloadID, res.State)
|
||||||
if res.Deduplicated {
|
if res.Deduplicated {
|
||||||
msg = fmt.Sprintf("Уже в работе #%d — %s.", res.DownloadID, res.State)
|
msg = fmt.Sprintf("Уже в работе #%s — %s.", res.DownloadID, res.State)
|
||||||
}
|
}
|
||||||
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil)
|
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil)
|
||||||
}
|
}
|
||||||
@@ -173,8 +173,10 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
action, id, val := parseCallback(cq.Data)
|
action, id, val := parseCallback(cq.Data)
|
||||||
if id == 0 {
|
if id == "" {
|
||||||
b.answer(cq.ID, "")
|
// Пустой/невалидный id — в т.ч. старые числовые кнопки, оставшиеся в
|
||||||
|
// истории чата до перехода на ULID: отвечаем понятно, а не молчим.
|
||||||
|
b.answer(cq.ID, "Кнопка устарела — откройте задачу в вебе")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
chatID := cq.Message.Chat.ID
|
chatID := cq.Message.Chat.ID
|
||||||
@@ -201,7 +203,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
|||||||
case "refine":
|
case "refine":
|
||||||
b.setPending(chatID, id)
|
b.setPending(chatID, id)
|
||||||
b.answer(cq.ID, "Жду подсказку")
|
b.answer(cq.ID, "Жду подсказку")
|
||||||
b.send(chatID, "Ответьте сообщением с подсказкой для #"+strconv.FormatInt(id, 10)+".", nil)
|
b.send(chatID, "Ответьте сообщением с подсказкой для #"+id+".", nil)
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
b.answer(cq.ID, "")
|
b.answer(cq.ID, "")
|
||||||
@@ -218,7 +220,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// refreshCard перечитывает задачу и обновляет карточку на месте.
|
// refreshCard перечитывает задачу и обновляет карточку на месте.
|
||||||
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64) {
|
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id string) {
|
||||||
rd, err := b.reviewer.ReviewData(ctx, id)
|
rd, err := b.reviewer.ReviewData(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.log.Warn("telegram refresh card failed", "download_id", id, "error", err)
|
b.log.Warn("telegram refresh card failed", "download_id", id, "error", err)
|
||||||
@@ -239,7 +241,7 @@ func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64
|
|||||||
// --- Notifier (worker.Notifier) ---
|
// --- Notifier (worker.Notifier) ---
|
||||||
|
|
||||||
// Notify шлёт карточку подтверждения/готовности всем доверенным пользователям.
|
// Notify шлёт карточку подтверждения/готовности всем доверенным пользователям.
|
||||||
func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyEvent) {
|
func (b *Bot) Notify(ctx context.Context, downloadID string, event worker.NotifyEvent) {
|
||||||
rd, err := b.reviewer.ReviewData(ctx, downloadID)
|
rd, err := b.reviewer.ReviewData(ctx, downloadID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.log.Warn("telegram notify review data", "download_id", downloadID, "error", err)
|
b.log.Warn("telegram notify review data", "download_id", downloadID, "error", err)
|
||||||
@@ -281,13 +283,13 @@ func (b *Bot) answer(callbackID, text string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) setPending(chatID, id int64) {
|
func (b *Bot) setPending(chatID int64, id string) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
b.pending[chatID] = id
|
b.pending[chatID] = id
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) takePending(chatID int64) (int64, bool) {
|
func (b *Bot) takePending(chatID int64) (string, bool) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
id, ok := b.pending[chatID]
|
id, ok := b.pending[chatID]
|
||||||
@@ -300,20 +302,21 @@ func (b *Bot) takePending(chatID int64) (int64, bool) {
|
|||||||
// opErr — сообщение публичного канала Telegram по доменной ошибке: нейтральный
|
// opErr — сообщение публичного канала Telegram по доменной ошибке: нейтральный
|
||||||
// текст + download_id для корреляции с логами (полная ошибка уже там, на
|
// текст + download_id для корреляции с логами (полная ошибка уже там, на
|
||||||
// доменной границе). Сырой err.Error() пользователю не показываем. Если id
|
// доменной границе). Сырой err.Error() пользователю не показываем. Если id
|
||||||
// операции ещё нет (downloadID == 0) — дружелюбный текст без ключа.
|
// операции ещё нет (downloadID == "") — дружелюбный текст без ключа.
|
||||||
func opErr(msg string, downloadID int64) string {
|
func opErr(msg string, downloadID string) string {
|
||||||
if downloadID > 0 {
|
if downloadID != "" {
|
||||||
return fmt.Sprintf("%s (download_id=%d).", msg, downloadID)
|
return fmt.Sprintf("%s (download_id=%s).", msg, downloadID)
|
||||||
}
|
}
|
||||||
return msg + "."
|
return msg + "."
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCallback разбирает "action[:id[:value]]".
|
// parseCallback разбирает "action[:id[:value]]". id валидируется как ULID
|
||||||
func parseCallback(data string) (action string, id int64, value string) {
|
// (входная граница); невалидный/устаревший (числовой) → пустая строка.
|
||||||
|
func parseCallback(data string) (action string, id string, value string) {
|
||||||
parts := strings.Split(data, ":")
|
parts := strings.Split(data, ":")
|
||||||
action = parts[0]
|
action = parts[0]
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
id, _ = strconv.ParseInt(parts[1], 10, 64)
|
id, _ = ident.Parse(parts[1])
|
||||||
}
|
}
|
||||||
if len(parts) > 2 {
|
if len(parts) > 2 {
|
||||||
value = parts[2]
|
value = parts[2]
|
||||||
|
|||||||
+59
-40
@@ -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
@@ -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
@@ -2,6 +2,7 @@ package worker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -10,13 +11,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// discover усыновляет новые раздачи: для каждого торрента с нашей категорией
|
// discover усыновляет новые раздачи: для каждого торрента с нашей категорией
|
||||||
// ИЛИ тегом, чьего infohash ещё нет в БД, заводит задачу downloading. Дальше
|
// ИЛИ тегом, чьих хешей ещё нет в БД, заводит задачу downloading. Дальше
|
||||||
// её ведёт обычный reconcile. Вызывается под w.mu.
|
// её ведёт обычный reconcile. Вызывается под w.mu.
|
||||||
//
|
//
|
||||||
// Корректность при гонке с Ingest (другая горутина): Ingest пишет строку в
|
// Корректность при гонке с Ingest (другая горутина): и adopt, и приём идут
|
||||||
// БД до добавления в qBit и ставит idempotency_key=infohash, на который есть
|
// через store.CreateDownloadIfNoActive — атомарный check-then-insert в одной
|
||||||
// UNIQUE-индекс. Поэтому даже если тик и Ingest столкнутся в окне «проверил →
|
// write-транзакции; при столкновении второй участник получает существующую
|
||||||
// вставляю», второй INSERT упадёт на индексе, и adopt просто пропустит.
|
// активную задачу и просто пропускает.
|
||||||
func (w *Worker) discover(ctx context.Context, torrents []qbt.Torrent) {
|
func (w *Worker) discover(ctx context.Context, torrents []qbt.Torrent) {
|
||||||
for _, t := range torrents {
|
for _, t := range torrents {
|
||||||
if w.tracked(t) {
|
if w.tracked(t) {
|
||||||
@@ -35,13 +36,13 @@ func (w *Worker) tracked(t qbt.Torrent) bool {
|
|||||||
|
|
||||||
// adopt заводит задачу под торрент, если его ещё не видели.
|
// adopt заводит задачу под торрент, если его ещё не видели.
|
||||||
func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
||||||
infohash := firstInfohash(t)
|
hashes := torrentHashes(t)
|
||||||
if infohash == "" {
|
if len(hashes) == 0 {
|
||||||
return // нечем идентифицировать (напр. ещё metaDL без хэша)
|
return // нечем идентифицировать (напр. ещё metaDL без хэша)
|
||||||
}
|
}
|
||||||
exists, err := w.store.ExistsByInfohash(ctx, infohash)
|
exists, err := w.store.ExistsByInfohash(ctx, hashes...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.log.Warn("discover exists check failed", "capability", capIngest, "infohash", infohash, "error", err)
|
w.log.Warn("discover exists check failed", "capability", capIngest, "infohash", hashes[0], "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if exists {
|
if exists {
|
||||||
@@ -49,33 +50,29 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
d := &store.Download{
|
d := &store.Download{
|
||||||
SourceType: store.SourceMagnet,
|
SourceType: store.SourceMagnet,
|
||||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
SourceRef: magnetURN(hashes[0]),
|
||||||
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
||||||
Infohash: store.NullString(infohash),
|
State: store.StateDownloading,
|
||||||
IdempotencyKey: store.NullString(infohash),
|
|
||||||
State: store.StateDownloading,
|
|
||||||
}
|
}
|
||||||
id, err := w.store.CreateDownload(ctx, d)
|
existing, err := w.store.CreateDownloadIfNoActive(ctx, d, hashes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Гонка: Ingest/другой тик мог вставить запись между проверкой и
|
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", hashes[0], "error", err)
|
||||||
// вставкой — UNIQUE-индекс это отсёк. Если запись появилась, всё ок.
|
|
||||||
if ex, _ := w.store.ExistsByInfohash(ctx, infohash); ex {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", infohash, "error", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return // гонка с Ingest/другим тиком: задача уже заведена — всё ок
|
||||||
|
}
|
||||||
// Базис сортировки — время добавления в источник; у усыновлённого оно уже
|
// Базис сортировки — время добавления в источник; у усыновлённого оно уже
|
||||||
// известно (created_at задачи было бы моментом усыновления, не добавления).
|
// известно (created_at задачи было бы моментом усыновления, не добавления).
|
||||||
if t.AddedOn > 0 {
|
if t.AddedOn > 0 {
|
||||||
if err := w.store.SetSourceAddedAt(ctx, id, time.Unix(t.AddedOn, 0)); err != nil {
|
if err := w.store.SetSourceAddedAt(ctx, d.ID, time.Unix(t.AddedOn, 0)); err != nil {
|
||||||
w.log.Warn("adopt set source_added_at failed",
|
w.log.Warn("adopt set source_added_at failed",
|
||||||
"capability", capIngest, "download_id", id, "error", err)
|
"capability", capIngest, "download_id", d.ID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
w.log.Info("discover adopted torrent",
|
w.log.Info("discover adopted torrent",
|
||||||
"capability", capIngest, "download_id", id, "infohash", infohash, "name", t.Name,
|
"capability", capIngest, "download_id", d.ID, "infohash", hashes[0], "name", t.Name,
|
||||||
"category", t.Category, "tags", t.Tags)
|
"category", t.Category, "tags", t.Tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,12 +89,33 @@ func hasTag(tags, tag string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// firstInfohash возвращает первый непустой infohash торрента (нижний регистр).
|
// torrentHashes — все непустые хеши торрента (нижний регистр, без дублей,
|
||||||
func firstInfohash(t qbt.Torrent) string {
|
// v1-приоритетный порядок: v1 раньше v2). Единственный сборщик хешей
|
||||||
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
// торрента для записи в БД: t.Hash берётся только когда qBittorrent не
|
||||||
if h != "" {
|
// отдал infohash_v1/v2 (старые версии API); у v2-only раздачи t.Hash — это
|
||||||
return strings.ToLower(h)
|
// УСЕЧЁННЫЙ до 40 hex v2-хеш, хранить его нельзя (по длине он неотличим от
|
||||||
|
// v1 и порождает битые btih-magnet при retry).
|
||||||
|
func torrentHashes(t qbt.Torrent) []string {
|
||||||
|
cands := []string{t.InfohashV1, t.InfohashV2}
|
||||||
|
if t.InfohashV1 == "" && t.InfohashV2 == "" {
|
||||||
|
cands = append(cands, t.Hash)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, h := range cands {
|
||||||
|
h = store.NormalizeHash(h)
|
||||||
|
if h != "" && !slices.Contains(out, h) {
|
||||||
|
out = append(out, h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// magnetURN — синтетический источник усыновлённой раздачи по её хешу:
|
||||||
|
// btih для v1, btmh (multihash sha256, префикс 1220) для v2. Хеш обязан
|
||||||
|
// быть полноразмерным (torrentHashes усечённые не отдаёт).
|
||||||
|
func magnetURN(h string) string {
|
||||||
|
if store.HashKind(h) == store.HashV2 {
|
||||||
|
return "magnet:?xt=urn:btmh:1220" + h
|
||||||
|
}
|
||||||
|
return "magnet:?xt=urn:btih:" + h
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import (
|
|||||||
const ihDisc = "7931aa3ed6666746012f5739d099b5bc64d72a16"
|
const ihDisc = "7931aa3ed6666746012f5739d099b5bc64d72a16"
|
||||||
|
|
||||||
func emptyStore() *fakeStore {
|
func emptyStore() *fakeStore {
|
||||||
return &fakeStore{downloads: map[int64]*store.Download{}}
|
return &fakeStore{downloads: map[string]*store.Download{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// findByInfohash возвращает усыновлённую задачу по infohash.
|
// findByInfohash возвращает усыновлённую задачу по infohash.
|
||||||
func findByInfohash(st *fakeStore, infohash string) *store.Download {
|
func findByInfohash(st *fakeStore, infohash string) *store.Download {
|
||||||
for _, d := range st.downloads {
|
for _, d := range st.downloads {
|
||||||
if d.Infohash.String == infohash {
|
if hasAnyHash(d, []string{infohash}) {
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,8 +38,8 @@ func TestDiscover_AdoptsByCategory(t *testing.T) {
|
|||||||
if d.State != store.StateDownloading || d.SourceType != store.SourceMagnet {
|
if d.State != store.StateDownloading || d.SourceType != store.SourceMagnet {
|
||||||
t.Errorf("adopted = %+v", d)
|
t.Errorf("adopted = %+v", d)
|
||||||
}
|
}
|
||||||
if d.IdempotencyKey.String != ihDisc {
|
if len(d.Infohashes) != 1 || d.Infohashes[0].Kind != store.HashV1 {
|
||||||
t.Errorf("idempotency_key = %q", d.IdempotencyKey.String)
|
t.Errorf("infohashes = %+v", d.Infohashes)
|
||||||
}
|
}
|
||||||
// Усыновление берёт заголовок из имени торрента qBittorrent и фиксирует
|
// Усыновление берёт заголовок из имени торрента qBittorrent и фиксирует
|
||||||
// время добавления (added_on) как базис сортировки.
|
// время добавления (added_on) как базис сортировки.
|
||||||
@@ -79,8 +79,8 @@ func TestDiscover_SkipsUntracked(t *testing.T) {
|
|||||||
func TestDiscover_SkipsExisting(t *testing.T) {
|
func TestDiscover_SkipsExisting(t *testing.T) {
|
||||||
st := emptyStore()
|
st := emptyStore()
|
||||||
// Уже есть задача (напр. терминальная done) — не переусыновляем.
|
// Уже есть задача (напр. терминальная done) — не переусыновляем.
|
||||||
st.downloads[1] = &store.Download{
|
st.downloads["1"] = &store.Download{
|
||||||
ID: 1, State: store.StateDone, Infohash: store.NullString(ihDisc),
|
ID: "1", State: store.StateDone, Infohashes: hashesOf("1", ihDisc),
|
||||||
}
|
}
|
||||||
w := newTestWorker(st, &fakeQbt{})
|
w := newTestWorker(st, &fakeQbt{})
|
||||||
w.discover(context.Background(), []qbt.Torrent{
|
w.discover(context.Background(), []qbt.Torrent{
|
||||||
@@ -104,9 +104,9 @@ func TestDiscover_SkipsNoInfohash(t *testing.T) {
|
|||||||
// уже скачанная раздача за один тик усыновляется и доходит до completed.
|
// уже скачанная раздача за один тик усыновляется и доходит до completed.
|
||||||
func TestPoll_CapturesSourceAddedAt(t *testing.T) {
|
func TestPoll_CapturesSourceAddedAt(t *testing.T) {
|
||||||
ih := "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
|
ih := "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
|
||||||
st := &fakeStore{downloads: map[int64]*store.Download{
|
st := &fakeStore{downloads: map[string]*store.Download{
|
||||||
1: {ID: 1, State: store.StateDownloading, SourceType: store.SourceMagnet,
|
"1": {ID: "1", State: store.StateDownloading, SourceType: store.SourceMagnet,
|
||||||
Infohash: store.NullString(ih), IdempotencyKey: store.NullString(ih)},
|
Infohashes: hashesOf("1", ih)},
|
||||||
}}
|
}}
|
||||||
qb := &fakeQbt{torrents: []qbt.Torrent{
|
qb := &fakeQbt{torrents: []qbt.Torrent{
|
||||||
{Hash: ih, Name: "X", Category: "jellybit", State: "downloading", AddedOn: 1_700_000_000},
|
{Hash: ih, Name: "X", Category: "jellybit", State: "downloading", AddedOn: 1_700_000_000},
|
||||||
@@ -116,7 +116,7 @@ func TestPoll_CapturesSourceAddedAt(t *testing.T) {
|
|||||||
if err := w.Poll(context.Background()); err != nil {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
t.Fatalf("Poll: %v", err)
|
||||||
}
|
}
|
||||||
if d := st.downloads[1]; !d.SourceAddedAt.Valid {
|
if d := st.downloads["1"]; !d.SourceAddedAt.Valid {
|
||||||
t.Fatalf("source_added_at не захвачен при поллинге активной задачи")
|
t.Fatalf("source_added_at не захвачен при поллинге активной задачи")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,14 +160,60 @@ func TestHasTag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFirstInfohash(t *testing.T) {
|
func TestTorrentHashes(t *testing.T) {
|
||||||
if got := firstInfohash(qbt.Torrent{Hash: "ABC"}); got != "abc" {
|
got := torrentHashes(qbt.Torrent{Hash: "ABC", InfohashV1: "abc", InfohashV2: "DEF"})
|
||||||
t.Errorf("got %q", got)
|
if len(got) != 2 || got[0] != "abc" || got[1] != "def" {
|
||||||
|
t.Errorf("got %v, want [abc def] (lowercase, без дублей, v1 первым)", got)
|
||||||
}
|
}
|
||||||
if got := firstInfohash(qbt.Torrent{InfohashV2: "DEF"}); got != "def" {
|
if got := torrentHashes(qbt.Torrent{}); len(got) != 0 {
|
||||||
t.Errorf("got %q", got)
|
t.Errorf("got %v, want empty", got)
|
||||||
}
|
}
|
||||||
if got := firstInfohash(qbt.Torrent{}); got != "" {
|
// Старый qBittorrent без infohash_v1/v2 — берём hash.
|
||||||
t.Errorf("got %q, want empty", got)
|
if got := torrentHashes(qbt.Torrent{Hash: "ABC"}); len(got) != 1 || got[0] != "abc" {
|
||||||
|
t.Errorf("legacy hash: got %v, want [abc]", got)
|
||||||
|
}
|
||||||
|
// v2-only: t.Hash — УСЕЧЁННЫЙ v2 (40 hex), хранить его нельзя.
|
||||||
|
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
got = torrentHashes(qbt.Torrent{Hash: v2[:40], InfohashV2: v2})
|
||||||
|
if len(got) != 1 || got[0] != v2 {
|
||||||
|
t.Errorf("v2-only: got %v, want только полный v2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Усыновление v2-only раздачи: SourceRef — валидный btmh-magnet из полного
|
||||||
|
// v2-хеша (не битый btih из усечённого), kind в БД — v2.
|
||||||
|
func TestDiscover_AdoptsV2Only(t *testing.T) {
|
||||||
|
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
st := emptyStore()
|
||||||
|
w := newTestWorker(st, &fakeQbt{})
|
||||||
|
w.discover(context.Background(), []qbt.Torrent{
|
||||||
|
{Hash: v2[:40], InfohashV2: v2, Name: "V2Only", Category: "jellybit", State: "downloading"},
|
||||||
|
})
|
||||||
|
d := findByInfohash(st, v2)
|
||||||
|
if d == nil {
|
||||||
|
t.Fatal("v2-only раздача не усыновлена")
|
||||||
|
}
|
||||||
|
if d.SourceRef != "magnet:?xt=urn:btmh:1220"+v2 {
|
||||||
|
t.Errorf("SourceRef = %q, want btmh с полным v2", d.SourceRef)
|
||||||
|
}
|
||||||
|
if len(d.Infohashes) != 1 || d.Infohashes[0].Kind != store.HashV2 {
|
||||||
|
t.Errorf("infohashes = %+v, want один v2 (усечённый не хранится)", d.Infohashes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Усыновление гибридного торрента записывает оба хеша.
|
||||||
|
func TestDiscover_AdoptsBothHashes(t *testing.T) {
|
||||||
|
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
st := emptyStore()
|
||||||
|
w := newTestWorker(st, &fakeQbt{})
|
||||||
|
w.discover(context.Background(), []qbt.Torrent{
|
||||||
|
{Hash: ihDisc, InfohashV1: ihDisc, InfohashV2: v2, Name: "Hybrid", Category: "jellybit", State: "downloading"},
|
||||||
|
})
|
||||||
|
d := findByInfohash(st, v2)
|
||||||
|
if d == nil {
|
||||||
|
t.Fatal("гибридная раздача не находится по v2-хешу")
|
||||||
|
}
|
||||||
|
if len(d.Infohashes) != 2 {
|
||||||
|
t.Errorf("infohashes = %+v, want v1+v2", d.Infohashes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
@@ -57,11 +56,11 @@ func (w *Worker) reconcileDesync(ctx context.Context, byHash map[string]qbt.Torr
|
|||||||
// reconcileOneDesync сверяет одну задачу: вычисляет присутствие источника (с
|
// reconcileOneDesync сверяет одну задачу: вычисляет присутствие источника (с
|
||||||
// дебаунсом) и цели, выводит состояние и переходит при изменении.
|
// дебаунсом) и цели, выводит состояние и переходит при изменении.
|
||||||
func (w *Worker) reconcileOneDesync(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
|
func (w *Worker) reconcileOneDesync(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
|
||||||
if !d.Infohash.Valid {
|
if len(d.Infohashes) == 0 {
|
||||||
return // нечем сопоставить источник
|
return // нечем сопоставить источник
|
||||||
}
|
}
|
||||||
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
|
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
||||||
_, sourceSeen := byHash[strings.ToLower(d.Infohash.String)]
|
_, sourceSeen := torrentFor(d, byHash)
|
||||||
|
|
||||||
// Дебаунс пропажи источника: считаем удалённым только после порога подряд
|
// Дебаунс пропажи источника: считаем удалённым только после порога подряд
|
||||||
// идущих промахов; любое появление сбрасывает счётчик.
|
// идущих промахов; любое появление сбрасывает счётчик.
|
||||||
@@ -104,7 +103,7 @@ func (w *Worker) debounceSource(ctx context.Context, d store.Download, sourceSee
|
|||||||
// targetPresent сообщает, существуют ли разложенные хардлинки задачи. Цель
|
// targetPresent сообщает, существуют ли разложенные хардлинки задачи. Цель
|
||||||
// считается присутствующей, только если существуют ВСЕ ссылки последнего
|
// считается присутствующей, только если существуют ВСЕ ссылки последнего
|
||||||
// батча; частичная пропажа — это отсутствие цели (библиотека сломана → relink).
|
// батча; частичная пропажа — это отсутствие цели (библиотека сломана → relink).
|
||||||
func (w *Worker) targetPresent(ctx context.Context, id int64) (bool, error) {
|
func (w *Worker) targetPresent(ctx context.Context, id string) (bool, error) {
|
||||||
batch, err := w.store.LatestBatchID(ctx, id)
|
batch, err := w.store.LatestBatchID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("latest batch: %w", err)
|
return false, fmt.Errorf("latest batch: %w", err)
|
||||||
@@ -180,10 +179,10 @@ func (w *Worker) reconcileRecovery(ctx context.Context, byHash map[string]qbt.To
|
|||||||
// reconcileOneRecovery возвращает одну зависшую задачу в поток, если её торрент
|
// reconcileOneRecovery возвращает одну зависшую задачу в поток, если её торрент
|
||||||
// присутствует и продвинулся за условие падения.
|
// присутствует и продвинулся за условие падения.
|
||||||
func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
|
func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
|
||||||
if !d.Infohash.Valid {
|
if len(d.Infohashes) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t, ok := byHash[strings.ToLower(d.Infohash.String)]
|
t, ok := torrentFor(d, byHash)
|
||||||
if !ok {
|
if !ok {
|
||||||
return // источника нет — оставляем как есть (вернёт ручной retry)
|
return // источника нет — оставляем как есть (вернёт ручной retry)
|
||||||
}
|
}
|
||||||
@@ -194,28 +193,22 @@ func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byH
|
|||||||
if want == "" {
|
if want == "" {
|
||||||
return // переходное состояние qBit (moving/checking) — ждём
|
return // переходное состояние qBit (moving/checking) — ждём
|
||||||
}
|
}
|
||||||
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
|
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
||||||
|
|
||||||
// Конфликт идемпотентности: пока задача лежала в failed, тот же infohash мог
|
// Возврат в активное состояние — только через атомарный гард инварианта:
|
||||||
// взять другая активная задача (idempotency_key снят при падении). Оба
|
// пока задача лежала в failed, тем же infohash могла завладеть другая
|
||||||
// целевых состояния (downloading/completed) нетерминальны → SetDownloadState
|
// активная задача (новый приём). Тогда старую оставляем в failed.
|
||||||
// восстановит idempotency_key = infohash; при занятом ключе упёрлись бы в
|
|
||||||
// unique-индекс. Поэтому проверяем владельца независимо от целевого состояния
|
|
||||||
// и оставляем старую задачу в failed.
|
|
||||||
other, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
|
|
||||||
if err != nil {
|
|
||||||
logctx.From(ctx).Warn("recovery active lookup failed", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if other != nil && other.ID != d.ID {
|
|
||||||
logctx.From(ctx).Info("recovery skipped, infohash taken by active download",
|
|
||||||
"conflict_download_id", other.ID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// error_code/error_msg не пишем — задача снова здорова; причину в лог, а не в
|
// error_code/error_msg не пишем — задача снова здорова; причину в лог, а не в
|
||||||
// поле ошибки (иначе она светилась бы в UI/REST как ошибка живой задачи).
|
// поле ошибки (иначе она светилась бы в UI/REST как ошибка живой задачи).
|
||||||
logctx.From(ctx).Info("recovery from failure", "to", want, "qbit_state", t.State)
|
if err := w.store.ActivateIfNoOtherActive(ctx, d.ID, want, "", ""); err != nil {
|
||||||
w.transition(ctx, d, want, "", "")
|
if errors.Is(err, store.ErrInfohashTaken) {
|
||||||
|
logctx.From(ctx).Info("recovery skipped, infohash taken by active download", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logctx.From(ctx).Warn("recovery activate failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logctx.From(ctx).Info("recovery from failure", "from", d.State, "to", want, "qbit_state", t.State)
|
||||||
}
|
}
|
||||||
|
|
||||||
// torrentProgressed сообщает, продвинулся ли торрент за условие, по которому
|
// torrentProgressed сообщает, продвинулся ли торрент за условие, по которому
|
||||||
@@ -256,10 +249,10 @@ func recoveredState(state string) store.State {
|
|||||||
// qBittorrent прямо сейчас. При отсутствии приводит состояние к реальности и
|
// qBittorrent прямо сейчас. При отсутствии приводит состояние к реальности и
|
||||||
// возвращает ErrConflict. Недоступность qBittorrent — честный отказ операции.
|
// возвращает ErrConflict. Недоступность qBittorrent — честный отказ операции.
|
||||||
func (w *Worker) ensureSourcePresent(ctx context.Context, d *store.Download, op string) error {
|
func (w *Worker) ensureSourcePresent(ctx context.Context, d *store.Download, op string) error {
|
||||||
if !d.Infohash.Valid {
|
if len(d.Infohashes) == 0 {
|
||||||
return fmt.Errorf("%s: download %d has no infohash", op, d.ID)
|
return fmt.Errorf("%s: download %s has no infohash", op, d.ID)
|
||||||
}
|
}
|
||||||
_, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
_, ok, err := w.torrentByInfohash(ctx, d.HashList())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%s: %w", op, err)
|
return fmt.Errorf("%s: %w", op, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ func newReconcileFixture(t *testing.T, state store.State, sourcePresent, makeTar
|
|||||||
}
|
}
|
||||||
|
|
||||||
st := newMemStore()
|
st := newMemStore()
|
||||||
d := completedDownload(1)
|
d := completedDownload("1")
|
||||||
d.State = state
|
d.State = state
|
||||||
st.put(d)
|
st.put(d)
|
||||||
st.links = append(st.links, store.FileLink{
|
st.links = append(st.links, store.FileLink{
|
||||||
DownloadID: 1, ApplyBatchID: "b1", SrcPath: src, DstPath: dst,
|
DownloadID: "1", ApplyBatchID: "b1", SrcPath: src, DstPath: dst,
|
||||||
Kind: "video", Status: "linked",
|
Kind: "video", Status: "linked",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ func TestReconcileMatrix(t *testing.T) {
|
|||||||
if err := f.w.Poll(context.Background()); err != nil {
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
t.Fatalf("Poll: %v", err)
|
||||||
}
|
}
|
||||||
if got := f.st.downloads[1].State; got != tc.want {
|
if got := f.st.downloads["1"].State; got != tc.want {
|
||||||
t.Errorf("state = %q, want %q", got, tc.want)
|
t.Errorf("state = %q, want %q", got, tc.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -78,7 +78,7 @@ func TestReconcileHealing(t *testing.T) {
|
|||||||
if err := f.w.Poll(context.Background()); err != nil {
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
t.Fatalf("Poll: %v", err)
|
||||||
}
|
}
|
||||||
if got := f.st.downloads[1].State; got != store.StateDone {
|
if got := f.st.downloads["1"].State; got != store.StateDone {
|
||||||
t.Errorf("state = %q, want done (healing)", got)
|
t.Errorf("state = %q, want done (healing)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,13 +88,13 @@ func TestReconcilePartialTargetLoss(t *testing.T) {
|
|||||||
f := newReconcileFixture(t, store.StateDone, true, true)
|
f := newReconcileFixture(t, store.StateDone, true, true)
|
||||||
missing := filepath.Join(filepath.Dir(f.dst), "Movie (2024).en.srt")
|
missing := filepath.Join(filepath.Dir(f.dst), "Movie (2024).en.srt")
|
||||||
f.st.links = append(f.st.links, store.FileLink{
|
f.st.links = append(f.st.links, store.FileLink{
|
||||||
DownloadID: 1, ApplyBatchID: "b1", SrcPath: "/x.srt", DstPath: missing,
|
DownloadID: "1", ApplyBatchID: "b1", SrcPath: "/x.srt", DstPath: missing,
|
||||||
Kind: "subtitle", Status: "linked",
|
Kind: "subtitle", Status: "linked",
|
||||||
})
|
})
|
||||||
if err := f.w.Poll(context.Background()); err != nil {
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
t.Fatalf("Poll: %v", err)
|
||||||
}
|
}
|
||||||
if got := f.st.downloads[1].State; got != store.StateTargetMissing {
|
if got := f.st.downloads["1"].State; got != store.StateTargetMissing {
|
||||||
t.Errorf("state = %q, want target_missing (частичная пропажа)", got)
|
t.Errorf("state = %q, want target_missing (частичная пропажа)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ func TestReconcileSkipsDeleted(t *testing.T) {
|
|||||||
if err := f.w.Poll(context.Background()); err != nil {
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
t.Fatalf("Poll: %v", err)
|
||||||
}
|
}
|
||||||
if got := f.st.downloads[1].State; got != store.StateDeleted {
|
if got := f.st.downloads["1"].State; got != store.StateDeleted {
|
||||||
t.Errorf("state = %q, want deleted (сверка не трогает терминальное)", got)
|
t.Errorf("state = %q, want deleted (сверка не трогает терминальное)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,17 +121,17 @@ func TestReconcileDebounce(t *testing.T) {
|
|||||||
if err := f.w.Poll(context.Background()); err != nil {
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll %d: %v", i, err)
|
t.Fatalf("Poll %d: %v", i, err)
|
||||||
}
|
}
|
||||||
if got := f.st.downloads[1].State; got != store.StateDone {
|
if got := f.st.downloads["1"].State; got != store.StateDone {
|
||||||
t.Fatalf("tick %d: state = %q, want done (до порога)", i, got)
|
t.Fatalf("tick %d: state = %q, want done (до порога)", i, got)
|
||||||
}
|
}
|
||||||
if got := f.st.downloads[1].SourceMissCount; got != i {
|
if got := f.st.downloads["1"].SourceMissCount; got != i {
|
||||||
t.Errorf("tick %d: miss = %d, want %d", i, got, i)
|
t.Errorf("tick %d: miss = %d, want %d", i, got, i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := f.w.Poll(context.Background()); err != nil { // третий промах
|
if err := f.w.Poll(context.Background()); err != nil { // третий промах
|
||||||
t.Fatalf("Poll 3: %v", err)
|
t.Fatalf("Poll 3: %v", err)
|
||||||
}
|
}
|
||||||
if got := f.st.downloads[1].State; got != store.StateOrphaned {
|
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
|
||||||
t.Fatalf("tick 3: state = %q, want orphaned (порог достигнут)", got)
|
t.Fatalf("tick 3: state = %q, want orphaned (порог достигнут)", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,10 +140,10 @@ func TestReconcileDebounce(t *testing.T) {
|
|||||||
if err := f.w.Poll(context.Background()); err != nil {
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll heal: %v", err)
|
t.Fatalf("Poll heal: %v", err)
|
||||||
}
|
}
|
||||||
if got := f.st.downloads[1].State; got != store.StateDone {
|
if got := f.st.downloads["1"].State; got != store.StateDone {
|
||||||
t.Errorf("state = %q, want done (источник вернулся)", got)
|
t.Errorf("state = %q, want done (источник вернулся)", got)
|
||||||
}
|
}
|
||||||
if got := f.st.downloads[1].SourceMissCount; got != 0 {
|
if got := f.st.downloads["1"].SourceMissCount; got != 0 {
|
||||||
t.Errorf("miss = %d, want 0 (сброс)", got)
|
t.Errorf("miss = %d, want 0 (сброс)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,18 +154,18 @@ func TestReconcileSkipsActiveStates(t *testing.T) {
|
|||||||
if err := f.w.Poll(context.Background()); err != nil {
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
t.Fatalf("Poll: %v", err)
|
||||||
}
|
}
|
||||||
if got := f.st.downloads[1].State; got != store.StateDownloading {
|
if got := f.st.downloads["1"].State; got != store.StateDownloading {
|
||||||
t.Errorf("state = %q, want downloading (сверка не трогает активные)", got)
|
t.Errorf("state = %q, want downloading (сверка не трогает активные)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUndoRejectedForOrphaned(t *testing.T) {
|
func TestUndoRejectedForOrphaned(t *testing.T) {
|
||||||
f := newReconcileFixture(t, store.StateOrphaned, false, true)
|
f := newReconcileFixture(t, store.StateOrphaned, false, true)
|
||||||
err := f.w.Undo(context.Background(), 1)
|
err := f.w.Undo(context.Background(), "1")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("ожидали отказ Undo для orphaned")
|
t.Fatal("ожидали отказ Undo для orphaned")
|
||||||
}
|
}
|
||||||
if got := f.st.downloads[1].State; got != store.StateOrphaned {
|
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
|
||||||
t.Errorf("state = %q, want orphaned (без изменений)", got)
|
t.Errorf("state = %q, want orphaned (без изменений)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,14 +173,14 @@ func TestUndoRejectedForOrphaned(t *testing.T) {
|
|||||||
func TestRelinkFromTargetMissing(t *testing.T) {
|
func TestRelinkFromTargetMissing(t *testing.T) {
|
||||||
// target_missing + источник на месте → relink ведёт в recognizing.
|
// target_missing + источник на месте → relink ведёт в recognizing.
|
||||||
f := newReconcileFixture(t, store.StateTargetMissing, true, false)
|
f := newReconcileFixture(t, store.StateTargetMissing, true, false)
|
||||||
if err := f.w.Relink(context.Background(), 1); err != nil {
|
if err := f.w.Relink(context.Background(), "1"); err != nil {
|
||||||
t.Fatalf("Relink: %v", err)
|
t.Fatalf("Relink: %v", err)
|
||||||
}
|
}
|
||||||
if got := f.st.downloads[1].State; got != store.StateRecognizing {
|
if got := f.st.downloads["1"].State; got != store.StateRecognizing {
|
||||||
t.Errorf("state = %q, want recognizing", got)
|
t.Errorf("state = %q, want recognizing", got)
|
||||||
}
|
}
|
||||||
if f.st.overrides[1][ovrForceReview] != "1" {
|
if f.st.overrides["1"][ovrForceReview] != "1" {
|
||||||
t.Errorf("force_review = %q, want 1", f.st.overrides[1][ovrForceReview])
|
t.Errorf("force_review = %q, want 1", f.st.overrides["1"][ovrForceReview])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,10 +189,10 @@ func TestPreflightFixesStaleState(t *testing.T) {
|
|||||||
// а цель на месте: relink немедленно приводит состояние к orphaned, не
|
// а цель на месте: relink немедленно приводит состояние к orphaned, не
|
||||||
// дожидаясь фоновой сверки.
|
// дожидаясь фоновой сверки.
|
||||||
f := newReconcileFixture(t, store.StateTargetMissing, false, true)
|
f := newReconcileFixture(t, store.StateTargetMissing, false, true)
|
||||||
if err := f.w.Relink(context.Background(), 1); err == nil {
|
if err := f.w.Relink(context.Background(), "1"); err == nil {
|
||||||
t.Fatal("ожидали отказ relink при пропавшем источнике")
|
t.Fatal("ожидали отказ relink при пропавшем источнике")
|
||||||
}
|
}
|
||||||
if got := f.st.downloads[1].State; got != store.StateOrphaned {
|
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
|
||||||
t.Errorf("state = %q, want orphaned (preflight привёл к реальности)", got)
|
t.Errorf("state = %q, want orphaned (preflight привёл к реальности)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ import (
|
|||||||
var addedRecent = time.Date(2026, 6, 14, 9, 59, 0, 0, time.UTC).Unix()
|
var addedRecent = time.Date(2026, 6, 14, 9, 59, 0, 0, time.UTC).Unix()
|
||||||
|
|
||||||
func oneFailed(state store.State, code, infohash, createdAt string) *fakeStore {
|
func oneFailed(state store.State, code, infohash, createdAt string) *fakeStore {
|
||||||
return &fakeStore{downloads: map[int64]*store.Download{
|
return &fakeStore{downloads: map[string]*store.Download{
|
||||||
1: {
|
"1": {
|
||||||
ID: 1,
|
ID: "1",
|
||||||
State: state,
|
State: state,
|
||||||
SourceType: store.SourceMagnet,
|
SourceType: store.SourceMagnet,
|
||||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
||||||
Infohash: store.NullString(infohash),
|
Infohashes: hashesOf("1", infohash),
|
||||||
ErrorCode: store.NullString(code),
|
ErrorCode: store.NullString(code),
|
||||||
CreatedAt: createdAt,
|
CreatedAt: createdAt,
|
||||||
},
|
},
|
||||||
@@ -52,7 +52,7 @@ func TestRecovery(t *testing.T) {
|
|||||||
if err := w.Poll(context.Background()); err != nil {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatalf("Poll: %v", err)
|
t.Fatalf("Poll: %v", err)
|
||||||
}
|
}
|
||||||
if got := st.downloads[1].State; got != tc.want {
|
if got := st.downloads["1"].State; got != tc.want {
|
||||||
t.Errorf("state = %q, want %q", got, tc.want)
|
t.Errorf("state = %q, want %q", got, tc.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -68,8 +68,8 @@ func TestRecoveryNoSourceStaysFailed(t *testing.T) {
|
|||||||
if err := w.Poll(context.Background()); err != nil {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if st.downloads[1].State != store.StateFailed {
|
if st.downloads["1"].State != store.StateFailed {
|
||||||
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads[1].State)
|
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads["1"].State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,11 +78,11 @@ func TestRecoveryNoSourceStaysFailed(t *testing.T) {
|
|||||||
func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
|
func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
|
||||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||||
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
||||||
st.downloads[2] = &store.Download{
|
st.downloads["2"] = &store.Download{
|
||||||
ID: 2,
|
ID: "2",
|
||||||
State: store.StateDownloading,
|
State: store.StateDownloading,
|
||||||
SourceType: store.SourceMagnet,
|
SourceType: store.SourceMagnet,
|
||||||
Infohash: store.NullString(ih),
|
Infohashes: hashesOf("2", ih),
|
||||||
CreatedAt: timeRecent,
|
CreatedAt: timeRecent,
|
||||||
}
|
}
|
||||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}}
|
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}}
|
||||||
@@ -90,8 +90,8 @@ func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
|
|||||||
if err := w.Poll(context.Background()); err != nil {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if st.downloads[1].State != store.StateFailed {
|
if st.downloads["1"].State != store.StateFailed {
|
||||||
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads[1].State)
|
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads["1"].State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,11 +101,11 @@ func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
|
|||||||
func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
|
func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
|
||||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||||
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
||||||
st.downloads[2] = &store.Download{
|
st.downloads["2"] = &store.Download{
|
||||||
ID: 2,
|
ID: "2",
|
||||||
State: store.StateDownloading,
|
State: store.StateDownloading,
|
||||||
SourceType: store.SourceMagnet,
|
SourceType: store.SourceMagnet,
|
||||||
Infohash: store.NullString(ih),
|
Infohashes: hashesOf("2", ih),
|
||||||
CreatedAt: timeRecent,
|
CreatedAt: timeRecent,
|
||||||
}
|
}
|
||||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "uploading", AddedOn: addedRecent}}}
|
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "uploading", AddedOn: addedRecent}}}
|
||||||
@@ -113,8 +113,8 @@ func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
|
|||||||
if err := w.Poll(context.Background()); err != nil {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if st.downloads[1].State != store.StateFailed {
|
if st.downloads["1"].State != store.StateFailed {
|
||||||
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads[1].State)
|
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads["1"].State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ func TestFailNotifyDebounce(t *testing.T) {
|
|||||||
w := newTestWorker(st, &fakeQbt{})
|
w := newTestWorker(st, &fakeQbt{})
|
||||||
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
|
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
|
||||||
w.SetNotifier(n)
|
w.SetNotifier(n)
|
||||||
d := *st.downloads[1]
|
d := *st.downloads["1"]
|
||||||
|
|
||||||
w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
|
w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
|
||||||
if e := waitNotify(t, n); e.ev != EventFailed {
|
if e := waitNotify(t, n); e.ev != EventFailed {
|
||||||
@@ -151,11 +151,11 @@ func TestRetryReattachesNoReadd(t *testing.T) {
|
|||||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}}
|
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}}
|
||||||
w := newTestWorker(st, qb)
|
w := newTestWorker(st, qb)
|
||||||
|
|
||||||
if err := w.Retry(context.Background(), 1); err != nil {
|
if err := w.Retry(context.Background(), "1"); err != nil {
|
||||||
t.Fatalf("Retry: %v", err)
|
t.Fatalf("Retry: %v", err)
|
||||||
}
|
}
|
||||||
if st.downloads[1].State != store.StateDownloading {
|
if st.downloads["1"].State != store.StateDownloading {
|
||||||
t.Fatalf("после retry ожидался downloading, got %q", st.downloads[1].State)
|
t.Fatalf("после retry ожидался downloading, got %q", st.downloads["1"].State)
|
||||||
}
|
}
|
||||||
if len(qb.added) != 0 {
|
if len(qb.added) != 0 {
|
||||||
t.Errorf("живой торрент не должен добавляться повторно, got %d Add", len(qb.added))
|
t.Errorf("живой торрент не должен добавляться повторно, got %d Add", len(qb.added))
|
||||||
@@ -164,7 +164,7 @@ func TestRetryReattachesNoReadd(t *testing.T) {
|
|||||||
if err := w.Poll(context.Background()); err != nil {
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if st.downloads[1].State != store.StateDownloading {
|
if st.downloads["1"].State != store.StateDownloading {
|
||||||
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads[1].State)
|
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads["1"].State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-60
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user