Идентичность на 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; валидация на старте:
|
||||
[docs/conventions/config.md](docs/conventions/config.md).
|
||||
- Время — всегда с явным TZ (сервер в `Europe/Moscow`).
|
||||
- Миграции БД (goose, `internal/store/migrations`) — при изменении структуры
|
||||
(таблица/столбец/индекс/связь) в том же change обновляем ER-схему
|
||||
[docs/specs/database.md](docs/specs/database.md).
|
||||
- Идентификаторы — TEXT ULID (lowercase) через `internal/ident`, без числовых
|
||||
AUTOINCREMENT; внешние id валидируются `ident.Parse` на границе:
|
||||
[docs/conventions/database.md](docs/conventions/database.md).
|
||||
- Миграции БД (goose, `internal/store/migrations`; SQL для DDL, Go — когда
|
||||
нужен код) — при изменении структуры (таблица/столбец/индекс/связь) в том же
|
||||
change обновляем ER-схему [docs/specs/database.md](docs/specs/database.md).
|
||||
|
||||
Кросс-каттинг конвенции (как пишем код, а не что система делает) живут в
|
||||
[docs/conventions/](docs/conventions/README.md) и не переносятся в OpenSpec.
|
||||
|
||||
@@ -184,7 +184,7 @@ func runServe(args []string) error {
|
||||
}, logger)
|
||||
wrk.SetNotifier(bot)
|
||||
// Приёмные падения (qbit_add) минуют worker — уведомляем напрямую.
|
||||
ingestor.SetFailureNotifier(func(id int64) {
|
||||
ingestor.SetFailureNotifier(func(id string) {
|
||||
bot.Notify(context.Background(), id, worker.EventFailed)
|
||||
})
|
||||
go bot.Run(ctx)
|
||||
|
||||
@@ -16,3 +16,6 @@
|
||||
(Ansible+Vault), валидация на старте.
|
||||
- [errors.md](errors.md) — ошибки: stdlib, обёртка `%w`, `errors.Is`/`As`,
|
||||
трансляция на внешней границе.
|
||||
- [database.md](database.md) — БД и идентификаторы: TEXT ULID PK через
|
||||
`internal/ident` (без AUTOINCREMENT), lowercase + нормализация на границах,
|
||||
естественные ключи у деталей.
|
||||
|
||||
@@ -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 без регулярок.
|
||||
|
||||
```json
|
||||
{"time":"2026-06-28T11:23:45.123456Z","level":"INFO","msg":"download accepted","capability":"ingest","download_id":"a1b2","infohash":"…","media_type":"movie","title":"Дюна: Часть вторая"}
|
||||
{"time":"2026-06-28T11:23:45.123456Z","level":"INFO","msg":"download accepted","capability":"ingest","download_id":"01jz2k7f8q9r3s4t5v6w7x8y9z","infohash":"…","media_type":"movie","title":"Дюна: Часть вторая"}
|
||||
```
|
||||
|
||||
## Сообщение
|
||||
@@ -100,14 +100,16 @@ log.Info(fmt.Sprintf("download %s accepted as movie", id))
|
||||
Если когда-нибудь поедем в несколько инстансов, добавим `service.version`
|
||||
одной строкой при старте.
|
||||
|
||||
## Корреляция по download_id
|
||||
## Корреляция по id сущности
|
||||
|
||||
Отдельный случайный `trace_id` не заводим — у загрузки уже есть стабильный
|
||||
осмысленный ключ: `download_id` (и `infohash`), он лежит в SQLite.
|
||||
Отдельный случайный `trace_id` не заводим — у сущностей уже есть стабильные
|
||||
осмысленные ключи: ULID-идентификаторы (`download_id`, `recognition_id`,
|
||||
`batch_id`, см. [database.md](database.md)), они лежат в SQLite.
|
||||
|
||||
- Заводим scoped-логгер на загрузку и протаскиваем его через
|
||||
`context.Context` сквозь асинхронные стадии (приём → скачивание →
|
||||
распознавание → раскладка), чтобы ключ дописывался на каждую запись сам:
|
||||
- Каждая запись, относящаяся к сущности, несёт её id в поле `<entity>_id`.
|
||||
Для загрузки — scoped-логгер, протаскиваемый через `context.Context`
|
||||
сквозь асинхронные стадии (приём → скачивание → распознавание →
|
||||
раскладка), чтобы ключ дописывался на каждую запись сам:
|
||||
|
||||
```go
|
||||
log := log.With("download_id", id, "infohash", ih)
|
||||
@@ -115,7 +117,10 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
```
|
||||
|
||||
- Все записи одной загрузки собираются одним фильтром:
|
||||
`jq 'select(.download_id=="a1b2")' app.jsonl`.
|
||||
`jq 'select(.download_id=="01jz2k7f8q9r3s4t5v6w7x8y9z")' app.jsonl`.
|
||||
- ULID глобально уникален across сущностей, поэтому штатно работает и
|
||||
простой grep по голому id — он находит все упоминания сущности независимо
|
||||
от имени поля: `grep 01jz2k7f8q9r3s4t5v6w7x8y9z app.jsonl`.
|
||||
|
||||
## Ошибки
|
||||
|
||||
|
||||
+37
-21
@@ -10,15 +10,19 @@
|
||||
> документации.
|
||||
>
|
||||
> Состояние на: миграции `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) →
|
||||
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.md).
|
||||
Первичные ключи — ULID (TEXT, lowercase), генерятся приложением
|
||||
(`internal/ident`) — см. [конвенцию](../conventions/database.md).
|
||||
|
||||
## ER-диаграмма
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
download ||--o{ download_infohash : "инфохэши (v1/v2)"
|
||||
download ||--o{ recognition : "распознавания"
|
||||
download ||--o{ hint : "подсказки"
|
||||
download ||--o{ override : "ручные правки"
|
||||
@@ -26,14 +30,12 @@ erDiagram
|
||||
recognition ||--o{ metadata_candidate : "кандидаты базы"
|
||||
|
||||
download {
|
||||
INTEGER id PK "AUTOINCREMENT"
|
||||
TEXT id PK "ULID (lowercase), генерится приложением"
|
||||
TEXT source_type "NOT NULL; magnet|torrent|url"
|
||||
TEXT source_ref "NOT NULL; magnet/url/путь"
|
||||
TEXT display_name "NOT NULL DEFAULT ''; имя раздачи (rename qBittorrent), заголовок в UI (миграция 0005)"
|
||||
TEXT context "NOT NULL DEFAULT ''"
|
||||
TEXT infohash "nullable; может появиться позже приёма"
|
||||
TEXT idempotency_key "nullable; UNIQUE если NOT NULL"
|
||||
TEXT state "NOT NULL; см. workflow.md"
|
||||
TEXT state "NOT NULL; см. workflow.md; активность выводится только из state"
|
||||
TEXT error_code "nullable"
|
||||
TEXT error_msg "nullable"
|
||||
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
|
||||
@@ -42,9 +44,16 @@ erDiagram
|
||||
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 {
|
||||
INTEGER id PK "AUTOINCREMENT"
|
||||
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||
TEXT id PK "ULID"
|
||||
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||
INTEGER attempt_no "NOT NULL DEFAULT 1"
|
||||
INTEGER is_current "NOT NULL DEFAULT 1; 0/1"
|
||||
TEXT media_type "nullable; movie|series"
|
||||
@@ -61,23 +70,23 @@ erDiagram
|
||||
}
|
||||
|
||||
hint {
|
||||
INTEGER id PK "AUTOINCREMENT"
|
||||
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||
TEXT id PK "ULID"
|
||||
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||
TEXT text "NOT NULL"
|
||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
||||
}
|
||||
|
||||
override {
|
||||
INTEGER id PK "AUTOINCREMENT"
|
||||
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||
TEXT id PK "ULID"
|
||||
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||
TEXT field "NOT NULL; UNIQUE(download_id, field)"
|
||||
TEXT value "NOT NULL"
|
||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
||||
}
|
||||
|
||||
metadata_candidate {
|
||||
INTEGER id PK "AUTOINCREMENT"
|
||||
INTEGER recognition_id FK "NOT NULL; ON DELETE CASCADE"
|
||||
TEXT id PK "ULID"
|
||||
TEXT recognition_id FK "NOT NULL; ON DELETE CASCADE"
|
||||
TEXT provider "NOT NULL"
|
||||
TEXT provider_id "NOT NULL"
|
||||
TEXT title "nullable"
|
||||
@@ -88,8 +97,8 @@ erDiagram
|
||||
}
|
||||
|
||||
file_link {
|
||||
INTEGER id PK "AUTOINCREMENT"
|
||||
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||
TEXT id PK "ULID"
|
||||
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||
TEXT apply_batch_id "NOT NULL; батч для точечного undo"
|
||||
TEXT src_path "NOT NULL; исходный файл раздачи"
|
||||
TEXT dst_path "NOT NULL; целевой хардлинк"
|
||||
@@ -101,10 +110,16 @@ erDiagram
|
||||
|
||||
## Связи и кардинальность
|
||||
|
||||
- `download` 1 — N `recognition` / `hint` / `override` / `file_link`;
|
||||
`recognition` 1 — N `metadata_candidate`. Все дочерние — с
|
||||
`ON DELETE CASCADE`: удаление загрузки уносит её распознавания, подсказки,
|
||||
правки и ссылки.
|
||||
- `download` 1 — N `download_infohash` / `recognition` / `hint` / `override`
|
||||
/ `file_link`; `recognition` 1 — N `metadata_candidate`. Все дочерние — с
|
||||
`ON DELETE CASCADE`: удаление загрузки уносит её хеши, распознавания,
|
||||
подсказки, правки и ссылки.
|
||||
- `download_infohash` — множество хешей одной загрузки (v1/v2 гибридного
|
||||
торрента); один и тот же infohash может принадлежать нескольким загрузкам
|
||||
во времени (повторный приём после терминального состояния). Инвариант «не
|
||||
более одной активной загрузки на infohash» держат guarded-методы store
|
||||
(`CreateDownloadIfNoActive`/`ActivateIfNoOtherActive`) в одной
|
||||
write-транзакции — на уровне схемы он не выражается (условие на `state`).
|
||||
- `download` ↔ `file_link` — один источник (раздача) ко многим разложенным
|
||||
файлам; внутри строки `file_link` связь `src_path → dst_path` — 1:1. Не
|
||||
каждый файл раздачи попадает в `file_link` (только распознанные медиа и
|
||||
@@ -112,8 +127,9 @@ erDiagram
|
||||
|
||||
## Индексы и ограничения
|
||||
|
||||
- `download`: `UNIQUE(idempotency_key) WHERE idempotency_key IS NOT NULL`;
|
||||
индекс по `state`.
|
||||
- `download`: индекс по `state`.
|
||||
- `download_infohash`: PK `(infohash, download_id)` (он же индекс поиска по
|
||||
хешу); индекс по `download_id`.
|
||||
- `recognition`: индекс по `download_id`.
|
||||
- `override`: `UNIQUE(download_id, field)`.
|
||||
- `metadata_candidate`: индекс по `recognition_id`.
|
||||
|
||||
@@ -7,6 +7,7 @@ require (
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4
|
||||
github.com/oklog/ulid/v2 v2.1.1
|
||||
github.com/pelletier/go-toml/v2 v2.2.3
|
||||
github.com/pressly/goose/v3 v3.22.1
|
||||
modernc.org/sqlite v1.34.1
|
||||
|
||||
@@ -30,6 +30,9 @@ github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4 h1
|
||||
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4/go.mod h1:H66QhXPJpUSdWschhL6u//v3ge96/qMnQ9mWp3efbxA=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
|
||||
@@ -12,12 +12,13 @@ import (
|
||||
// --- Страница просмотра одной загрузки ---
|
||||
|
||||
type downloadDetailView struct {
|
||||
ID int64
|
||||
ID string
|
||||
Title string
|
||||
SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте»
|
||||
SourceFull string // полный источник (magnet) — блок «Информация о торренте»
|
||||
Infohash string
|
||||
Infohash string // первый хеш (для шапки)
|
||||
InfohashShort string
|
||||
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
|
||||
Context string
|
||||
State 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) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "некорректный id", http.StatusBadRequest)
|
||||
// Невалидный id = несуществующая сущность; в БД не ходим.
|
||||
http.Error(w, "задача не найдена", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
@@ -85,8 +87,9 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
Title: detailTitle(d, rd),
|
||||
SourceType: string(d.SourceType),
|
||||
SourceFull: d.SourceRef,
|
||||
Infohash: d.Infohash.String,
|
||||
InfohashShort: shortenHash(d.Infohash.String),
|
||||
Infohash: d.PrimaryInfohash(),
|
||||
InfohashShort: shortenHash(d.PrimaryInfohash()),
|
||||
Infohashes: d.HashList(),
|
||||
Context: d.Context,
|
||||
State: string(d.State),
|
||||
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)
|
||||
|
||||
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/middleware"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
@@ -35,15 +36,15 @@ type Ingestor interface {
|
||||
|
||||
// Commander исполняет команды над задачей (worker.Worker).
|
||||
type Commander interface {
|
||||
Cancel(ctx context.Context, id int64) error
|
||||
Retry(ctx context.Context, id int64) error
|
||||
Cancel(ctx context.Context, id string) error
|
||||
Retry(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// Reader читает задачи (store.Store).
|
||||
type Reader interface {
|
||||
ListDownloads(ctx context.Context) ([]store.Download, error)
|
||||
ListDownloadsPage(ctx context.Context, f store.ListFilter) ([]store.Download, int, error)
|
||||
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
||||
GetDownload(ctx context.Context, id string) (*store.Download, error)
|
||||
}
|
||||
|
||||
// Deps — зависимости транспорта.
|
||||
@@ -178,7 +179,7 @@ type pageLink struct {
|
||||
}
|
||||
|
||||
type downloadView struct {
|
||||
ID int64
|
||||
ID string
|
||||
Title string // отображаемый заголовок карточки
|
||||
Infohash string // полный (для копирования)
|
||||
InfohashShort string // усечённый (для показа)
|
||||
@@ -269,7 +270,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
// Живой прогресс активных загрузок — со значениями уже в первом кадре
|
||||
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
|
||||
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)
|
||||
}
|
||||
view.Downloads = append(view.Downloads, v)
|
||||
@@ -394,15 +395,15 @@ func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
|
||||
// --- REST API ---
|
||||
|
||||
type downloadDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceType string `json:"source_type"`
|
||||
Infohash string `json:"infohash,omitempty"`
|
||||
Context string `json:"context,omitempty"`
|
||||
State string `json:"state"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
ErrorMsg string `json:"error_msg,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"` // ULID (lowercase)
|
||||
SourceType string `json:"source_type"`
|
||||
Infohashes []string `json:"infohashes,omitempty"` // все хеши загрузки (v1 раньше v2)
|
||||
Context string `json:"context,omitempty"`
|
||||
State string `json:"state"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
ErrorMsg string `json:"error_msg,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type addRequest struct {
|
||||
@@ -411,16 +412,16 @@ type addRequest struct {
|
||||
}
|
||||
|
||||
type addResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Infohash string `json:"infohash"`
|
||||
State string `json:"state"`
|
||||
Deduplicated bool `json:"deduplicated"`
|
||||
ID string `json:"id"` // ULID (lowercase)
|
||||
Infohashes []string `json:"infohashes"` // хеши принятого источника (v1 раньше v2) — симметрично downloadDTO
|
||||
State string `json:"state"`
|
||||
Deduplicated bool `json:"deduplicated"`
|
||||
}
|
||||
|
||||
func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
|
||||
downloads, err := s.deps.Reader.ListDownloads(r.Context())
|
||||
if err != nil {
|
||||
s.apiErr(w, r, err, 0)
|
||||
s.apiErr(w, r, err, "")
|
||||
return
|
||||
}
|
||||
out := make([]downloadDTO, 0, len(downloads))
|
||||
@@ -433,7 +434,8 @@ func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
|
||||
// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
|
||||
writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
|
||||
return
|
||||
}
|
||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||
@@ -448,7 +450,7 @@ func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
|
||||
var req addRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный запрос", 0))
|
||||
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный запрос", ""))
|
||||
return
|
||||
}
|
||||
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
|
||||
@@ -465,7 +467,7 @@ func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, status, addResponse{
|
||||
ID: res.DownloadID,
|
||||
Infohash: res.Infohash,
|
||||
Infohashes: res.Infohashes,
|
||||
State: string(res.State),
|
||||
Deduplicated: res.Deduplicated,
|
||||
})
|
||||
@@ -479,10 +481,11 @@ func (s *server) handleAPIRetry(w http.ResponseWriter, r *http.Request) {
|
||||
s.apiCommand(w, r, s.deps.Commander.Retry)
|
||||
}
|
||||
|
||||
func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, int64) error) {
|
||||
func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, string) error) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
|
||||
// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
|
||||
writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
|
||||
return
|
||||
}
|
||||
if err := cmd(r.Context(), id); err != nil {
|
||||
@@ -494,7 +497,7 @@ func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(con
|
||||
}
|
||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]int64{"id": id})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"id": id})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toDTO(*d))
|
||||
@@ -506,7 +509,7 @@ func toDTO(d store.Download) downloadDTO {
|
||||
return downloadDTO{
|
||||
ID: d.ID,
|
||||
SourceType: string(d.SourceType),
|
||||
Infohash: d.Infohash.String,
|
||||
Infohashes: d.HashList(),
|
||||
Context: d.Context,
|
||||
State: string(d.State),
|
||||
ErrorCode: d.ErrorCode.String,
|
||||
@@ -521,8 +524,8 @@ func toView(d store.Download) downloadView {
|
||||
return downloadView{
|
||||
ID: d.ID,
|
||||
Title: downloadTitle(d),
|
||||
Infohash: d.Infohash.String,
|
||||
InfohashShort: shortenHash(d.Infohash.String),
|
||||
Infohash: d.PrimaryInfohash(),
|
||||
InfohashShort: shortenHash(d.PrimaryInfohash()),
|
||||
Context: d.Context,
|
||||
State: state,
|
||||
Error: d.ErrorMsg.String,
|
||||
@@ -586,12 +589,21 @@ func shorten(s string, n int) string {
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
func pathID(r *http.Request) (int64, error) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.New("invalid id")
|
||||
// pathID валидирует {id} из URL как ULID и нормализует к lowercase — до
|
||||
// любого обращения к БД (сравнение в SQLite побайтовое). Невалидный id
|
||||
// трактуется вызывающими как несуществующая сущность (404).
|
||||
func pathID(r *http.Request) (string, error) {
|
||||
return ident.Parse(chi.URLParam(r, "id"))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -627,9 +639,9 @@ func classifyErr(err error) (int, string) {
|
||||
// errBody — тело ошибки REST API: нейтральное сообщение + корреляционный ключ
|
||||
// для владельца (download_id, если операция привязана к загрузке, иначе
|
||||
// request_id запроса), по которому он найдёт полную ошибку в логах.
|
||||
func errBody(r *http.Request, msg string, downloadID int64) map[string]any {
|
||||
func errBody(r *http.Request, msg string, downloadID string) map[string]any {
|
||||
body := map[string]any{"error": msg}
|
||||
if downloadID > 0 {
|
||||
if downloadID != "" {
|
||||
body["download_id"] = downloadID
|
||||
} else {
|
||||
body["request_id"] = middleware.GetReqID(r.Context())
|
||||
@@ -638,7 +650,7 @@ func errBody(r *http.Request, msg string, downloadID int64) map[string]any {
|
||||
}
|
||||
|
||||
// apiErr пишет ответ об ошибке REST API по доменной ошибке (статус + тело).
|
||||
func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downloadID int64) {
|
||||
func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downloadID string) {
|
||||
status, msg := classifyErr(err)
|
||||
writeJSON(w, status, errBody(r, msg, downloadID))
|
||||
}
|
||||
@@ -646,10 +658,10 @@ func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downl
|
||||
// userErr — сообщение публичного канала для веб-UI: нейтральный текст по
|
||||
// доменной ошибке + корреляционный ключ владельцу (download_id, если операция
|
||||
// привязана к загрузке, иначе request_id). Сырой текст ошибки наружу не идёт.
|
||||
func userErr(r *http.Request, err error, downloadID int64) string {
|
||||
func userErr(r *http.Request, err error, downloadID string) string {
|
||||
_, msg := classifyErr(err)
|
||||
if downloadID > 0 {
|
||||
return fmt.Sprintf("%s (download_id=%d)", msg, downloadID)
|
||||
if downloadID != "" {
|
||||
return fmt.Sprintf("%s (download_id=%s)", msg, downloadID)
|
||||
}
|
||||
return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
|
||||
}
|
||||
|
||||
@@ -21,6 +21,14 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
|
||||
// Валидные lowercase-ULID для маршрутов (pathID валидирует формат).
|
||||
const (
|
||||
tid = "01arz3ndektsv4rrffq69g5fav"
|
||||
tid2 = "01arz3ndektsv4rrffq69g5fb0"
|
||||
cid = "01arz3ndektsv4rrffq69g5fc0"
|
||||
cid2 = "01arz3ndektsv4rrffq69g5fd0"
|
||||
)
|
||||
|
||||
type fakeIngestor struct {
|
||||
res ingest.Result
|
||||
err error
|
||||
@@ -33,12 +41,12 @@ func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Res
|
||||
}
|
||||
|
||||
type fakeCommander struct {
|
||||
cancelled []int64
|
||||
retried []int64
|
||||
cancelled []string
|
||||
retried []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeCommander) Cancel(_ context.Context, id int64) error {
|
||||
func (f *fakeCommander) Cancel(_ context.Context, id string) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
@@ -46,7 +54,7 @@ func (f *fakeCommander) Cancel(_ context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeCommander) Retry(_ context.Context, id int64) error {
|
||||
func (f *fakeCommander) Retry(_ context.Context, id string) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
@@ -76,7 +84,7 @@ func (f *fakeReader) ListDownloadsPage(_ context.Context, flt store.ListFilter)
|
||||
return f.list, total, nil
|
||||
}
|
||||
|
||||
func (f *fakeReader) GetDownload(_ context.Context, id int64) (*store.Download, error) {
|
||||
func (f *fakeReader) GetDownload(_ context.Context, id string) (*store.Download, error) {
|
||||
if f.get != nil {
|
||||
return f.get, nil
|
||||
}
|
||||
@@ -98,7 +106,7 @@ func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
|
||||
}
|
||||
|
||||
func TestAPIAdd(t *testing.T) {
|
||||
ing := &fakeIngestor{res: ingest.Result{DownloadID: 1, Infohash: "abc", State: store.StateDownloading}}
|
||||
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, Infohashes: []string{"abc"}, State: store.StateDownloading}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads", "application/json",
|
||||
@@ -112,7 +120,7 @@ func TestAPIAdd(t *testing.T) {
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
if got["id"].(float64) != 1 || got["state"] != "downloading" {
|
||||
if got["id"] != tid || got["state"] != "downloading" {
|
||||
t.Errorf("body = %v", got)
|
||||
}
|
||||
if ing.lastReq.Context != "Дюна" {
|
||||
@@ -138,8 +146,9 @@ func TestAPIAddBadInput(t *testing.T) {
|
||||
|
||||
func TestAPIList(t *testing.T) {
|
||||
reader := &fakeReader{list: []store.Download{
|
||||
{ID: 2, SourceType: store.SourceMagnet, State: store.StateCompleted, Infohash: store.NullString("abc")},
|
||||
{ID: 1, SourceType: store.SourceMagnet, State: store.StateDownloading},
|
||||
{ID: tid2, SourceType: store.SourceMagnet, State: store.StateCompleted,
|
||||
Infohashes: []store.Infohash{{DownloadID: tid2, Infohash: "abc", Kind: store.HashV1}}},
|
||||
{ID: tid, SourceType: store.SourceMagnet, State: store.StateDownloading},
|
||||
}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||
|
||||
@@ -153,7 +162,8 @@ func TestAPIList(t *testing.T) {
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0]["state"] != "completed" || got[0]["infohash"] != "abc" {
|
||||
hashes, _ := got[0]["infohashes"].([]any)
|
||||
if got[0]["state"] != "completed" || len(hashes) != 1 || hashes[0] != "abc" {
|
||||
t.Errorf("first = %v", got[0])
|
||||
}
|
||||
}
|
||||
@@ -162,7 +172,7 @@ func TestAPICancel(t *testing.T) {
|
||||
cmd := &fakeCommander{}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/5/cancel", "", nil)
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -170,7 +180,7 @@ func TestAPICancel(t *testing.T) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if len(cmd.cancelled) != 1 || cmd.cancelled[0] != 5 {
|
||||
if len(cmd.cancelled) != 1 || cmd.cancelled[0] != tid {
|
||||
t.Errorf("cancel вызван неверно: %v", cmd.cancelled)
|
||||
}
|
||||
}
|
||||
@@ -179,7 +189,7 @@ func TestUIRetry(t *testing.T) {
|
||||
cmd := &fakeCommander{}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/ui/downloads/5/retry", "application/x-www-form-urlencoded", nil)
|
||||
resp, err := http.Post(srv.URL+"/ui/downloads/"+tid+"/retry", "application/x-www-form-urlencoded", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -187,7 +197,7 @@ func TestUIRetry(t *testing.T) {
|
||||
if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if len(cmd.retried) != 1 || cmd.retried[0] != 5 {
|
||||
if len(cmd.retried) != 1 || cmd.retried[0] != tid {
|
||||
t.Errorf("retry вызван неверно: %v", cmd.retried)
|
||||
}
|
||||
}
|
||||
@@ -197,7 +207,7 @@ func TestAPICommandConflict(t *testing.T) {
|
||||
cmd := &fakeCommander{err: fmt.Errorf("cancel: download 5 in wrong state: %w", worker.ErrConflict)}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/5/cancel", "", nil)
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -207,14 +217,14 @@ func TestAPICommandConflict(t *testing.T) {
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
if got["download_id"].(float64) != 5 {
|
||||
if got["download_id"] != tid {
|
||||
t.Errorf("download_id корреляции нет: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexRenders(t *testing.T) {
|
||||
reader := &fakeReader{list: []store.Download{
|
||||
{ID: 1, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
|
||||
{ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
|
||||
}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||
|
||||
@@ -239,7 +249,7 @@ func (e ingestErr) Error() string { return string(e) }
|
||||
func TestIndexTitleFromDisplayName(t *testing.T) {
|
||||
// Заголовок карточки — имя раздачи (display_name), а не сырой magnet.
|
||||
reader := &fakeReader{list: []store.Download{{
|
||||
ID: 1, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
|
||||
ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
|
||||
DisplayName: "Дюна: Часть вторая (2024)", State: store.StateDownloading,
|
||||
}}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||
@@ -301,11 +311,11 @@ func TestIndexPageClamp(t *testing.T) {
|
||||
func detailReviewData(provider, providerID, chosenID, chosenURL string) *worker.ReviewData {
|
||||
rd := &worker.ReviewData{
|
||||
Download: store.Download{
|
||||
ID: 1, State: store.StateReview, SourceType: store.SourceMagnet,
|
||||
ID: tid, State: store.StateReview, SourceType: store.SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:deadbeef", DisplayName: "Дюна",
|
||||
Infohash: store.NullString("deadbeef"),
|
||||
Infohashes: []store.Infohash{{DownloadID: tid, Infohash: "deadbeef", Kind: store.HashV1}},
|
||||
},
|
||||
Recognition: &store.Recognition{ID: 1, DownloadID: 1, IsCurrent: true},
|
||||
Recognition: &store.Recognition{ID: "1", DownloadID: tid, IsCurrent: true},
|
||||
Plan: recognize.Plan{
|
||||
Type: recognize.MediaMovie, Title: "Дюна", Year: 2024,
|
||||
Files: []recognize.PlanFile{{Src: "dune.mkv", Role: recognize.RoleMain}},
|
||||
@@ -316,7 +326,7 @@ func detailReviewData(provider, providerID, chosenID, chosenURL string) *worker.
|
||||
}
|
||||
if chosenID != "" {
|
||||
rd.Candidates = []store.MetadataCandidate{{
|
||||
ID: 10, Provider: "tmdb", ProviderID: chosenID, Chosen: true,
|
||||
ID: cid, Provider: "tmdb", ProviderID: chosenID, Chosen: true,
|
||||
URL: store.NullString(chosenURL),
|
||||
}}
|
||||
}
|
||||
@@ -329,7 +339,7 @@ func TestDownloadMatchLinkFromCandidate(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/download/1")
|
||||
resp, err := http.Get(srv.URL + "/download/" + tid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -347,7 +357,7 @@ func TestDownloadMatchLinkMismatchUsesBuilt(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/download/1")
|
||||
resp, err := http.Get(srv.URL + "/download/" + tid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -366,7 +376,7 @@ func TestDownloadTorrentInfoBlock(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/download/1")
|
||||
resp, err := http.Get(srv.URL + "/download/" + tid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -384,81 +394,81 @@ func TestDownloadTorrentInfoBlock(t *testing.T) {
|
||||
type fakeReviewer struct {
|
||||
data *worker.ReviewData
|
||||
applyErr error
|
||||
refined map[int64]string
|
||||
typed map[int64]string
|
||||
ignored map[int64]string
|
||||
chosen map[int64]int64
|
||||
providerSet map[int64]string
|
||||
applied []int64
|
||||
deferred []int64
|
||||
undone []int64
|
||||
relinked []int64
|
||||
rerecognized []int64
|
||||
cleared []int64
|
||||
refined map[string]string
|
||||
typed map[string]string
|
||||
ignored map[string]string
|
||||
chosen map[string]string
|
||||
providerSet map[string]string
|
||||
applied []string
|
||||
deferred []string
|
||||
undone []string
|
||||
relinked []string
|
||||
rerecognized []string
|
||||
cleared []string
|
||||
}
|
||||
|
||||
func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) {
|
||||
func (f *fakeReviewer) ReviewData(_ context.Context, _ string) (*worker.ReviewData, error) {
|
||||
return f.data, nil
|
||||
}
|
||||
func (f *fakeReviewer) Apply(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Apply(_ context.Context, id string) error {
|
||||
if f.applyErr != nil {
|
||||
return f.applyErr
|
||||
}
|
||||
f.applied = append(f.applied, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Refine(_ context.Context, id int64, hint string) error {
|
||||
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
|
||||
if f.refined == nil {
|
||||
f.refined = map[int64]string{}
|
||||
f.refined = map[string]string{}
|
||||
}
|
||||
f.refined[id] = hint
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) SetType(_ context.Context, id int64, t string) error {
|
||||
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
|
||||
if f.typed == nil {
|
||||
f.typed = map[int64]string{}
|
||||
f.typed = map[string]string{}
|
||||
}
|
||||
f.typed[id] = t
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) IgnoreFile(_ context.Context, id int64, src string) error {
|
||||
func (f *fakeReviewer) IgnoreFile(_ context.Context, id string, src string) error {
|
||||
if f.ignored == nil {
|
||||
f.ignored = map[int64]string{}
|
||||
f.ignored = map[string]string{}
|
||||
}
|
||||
f.ignored[id] = src
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Defer(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Defer(_ context.Context, id string) error {
|
||||
f.deferred = append(f.deferred, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Undo(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Undo(_ context.Context, id string) error {
|
||||
f.undone = append(f.undone, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Relink(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Relink(_ context.Context, id string) error {
|
||||
f.relinked = append(f.relinked, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Rerecognize(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Rerecognize(_ context.Context, id string) error {
|
||||
f.rerecognized = append(f.rerecognized, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error {
|
||||
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID string) error {
|
||||
if f.chosen == nil {
|
||||
f.chosen = map[int64]int64{}
|
||||
f.chosen = map[string]string{}
|
||||
}
|
||||
f.chosen[id] = candidateID
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) SetProviderID(_ context.Context, id int64, provider, providerID string) error {
|
||||
func (f *fakeReviewer) SetProviderID(_ context.Context, id string, provider, providerID string) error {
|
||||
if f.providerSet == nil {
|
||||
f.providerSet = map[int64]string{}
|
||||
f.providerSet = map[string]string{}
|
||||
}
|
||||
f.providerSet[id] = provider + ":" + providerID
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) ClearProvider(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) ClearProvider(_ context.Context, id string) error {
|
||||
f.cleared = append(f.cleared, id)
|
||||
return nil
|
||||
}
|
||||
@@ -466,9 +476,9 @@ func (f *fakeReviewer) ClearProvider(_ context.Context, id int64) error {
|
||||
func seriesReviewData() *worker.ReviewData {
|
||||
s, e := 2, 1
|
||||
return &worker.ReviewData{
|
||||
Download: store.Download{ID: 1, State: store.StateReview, SourceRef: "magnet:?xt=urn:btih:abc"},
|
||||
Download: store.Download{ID: tid, State: store.StateReview, SourceRef: "magnet:?xt=urn:btih:abc"},
|
||||
Recognition: &store.Recognition{
|
||||
ID: 1, DownloadID: 1, IsCurrent: true, Reasons: `["нет матча в базе"]`,
|
||||
ID: "1", DownloadID: tid, IsCurrent: true, Reasons: `["нет матча в базе"]`,
|
||||
},
|
||||
Plan: recognize.Plan{
|
||||
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
|
||||
@@ -480,9 +490,9 @@ func seriesReviewData() *worker.ReviewData {
|
||||
{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
|
||||
},
|
||||
Candidates: []store.MetadataCandidate{
|
||||
{ID: 10, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
|
||||
{ID: cid, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
|
||||
Year: sql.NullInt64{Int64: 2014, Valid: true}},
|
||||
{ID: 11, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")},
|
||||
{ID: cid2, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")},
|
||||
},
|
||||
Hints: []string{"второй сезон"},
|
||||
}
|
||||
@@ -500,7 +510,7 @@ func TestReviewRenders(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/review/1")
|
||||
resp, err := http.Get(srv.URL + "/review/" + tid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -529,7 +539,7 @@ func TestReviewShowsMatchLink(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/review/1")
|
||||
resp, err := http.Get(srv.URL + "/review/" + tid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -545,16 +555,16 @@ func TestChooseCandidate(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/1/candidate",
|
||||
map[string][]string{"candidate_id": {"10"}})
|
||||
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/candidate",
|
||||
map[string][]string{"candidate_id": {cid}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if rv.chosen[1] != 10 {
|
||||
t.Errorf("ChooseCandidate получил %d", rv.chosen[1])
|
||||
if rv.chosen[tid] != cid {
|
||||
t.Errorf("ChooseCandidate получил %q", rv.chosen[tid])
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
|
||||
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) {
|
||||
t.Errorf("Location = %q", loc)
|
||||
}
|
||||
}
|
||||
@@ -565,18 +575,18 @@ func TestSetProviderAndNoBase(t *testing.T) {
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
cl := noRedirectClient()
|
||||
|
||||
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/provider",
|
||||
if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/provider",
|
||||
map[string][]string{"provider": {"tvdb"}, "provider_id": {"269613"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rv.providerSet[1] != "tvdb:269613" {
|
||||
t.Errorf("SetProviderID получил %q", rv.providerSet[1])
|
||||
if rv.providerSet[tid] != "tvdb:269613" {
|
||||
t.Errorf("SetProviderID получил %q", rv.providerSet[tid])
|
||||
}
|
||||
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/1/nobase", "", nil); err != nil {
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/nobase", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rv.cleared) != 1 || rv.cleared[0] != 1 {
|
||||
if len(rv.cleared) != 1 || rv.cleared[0] != tid {
|
||||
t.Errorf("ClearProvider = %v", rv.cleared)
|
||||
}
|
||||
}
|
||||
@@ -586,7 +596,7 @@ func TestApplyRedirectsToIndex(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/1/apply", "", nil)
|
||||
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/"+tid+"/apply", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -607,13 +617,13 @@ func TestApplyCollisionRedirectsToReview(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/1/apply", "", nil)
|
||||
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/"+tid+"/apply", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
|
||||
t.Errorf("Location = %q, want /review/1?err=...", loc)
|
||||
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) {
|
||||
t.Errorf("Location = %q, want /review/{id}?err=...", loc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,16 +632,16 @@ func TestRefinePostsHint(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/1/refine",
|
||||
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/refine",
|
||||
map[string][]string{"hint": {"это второй сезон"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if rv.refined[1] != "это второй сезон" {
|
||||
t.Errorf("Refine получил %q", rv.refined[1])
|
||||
if rv.refined[tid] != "это второй сезон" {
|
||||
t.Errorf("Refine получил %q", rv.refined[tid])
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
|
||||
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) {
|
||||
t.Errorf("Location = %q", loc)
|
||||
}
|
||||
}
|
||||
@@ -642,20 +652,20 @@ func TestIgnoreAndType(t *testing.T) {
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
cl := noRedirectClient()
|
||||
|
||||
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/ignore",
|
||||
if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/ignore",
|
||||
map[string][]string{"src": {"Fargo/sample.mkv"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rv.ignored[1] != "Fargo/sample.mkv" {
|
||||
t.Errorf("IgnoreFile получил %q", rv.ignored[1])
|
||||
if rv.ignored[tid] != "Fargo/sample.mkv" {
|
||||
t.Errorf("IgnoreFile получил %q", rv.ignored[tid])
|
||||
}
|
||||
|
||||
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/type",
|
||||
if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/type",
|
||||
map[string][]string{"type": {"movie"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rv.typed[1] != "movie" {
|
||||
t.Errorf("SetType получил %q", rv.typed[1])
|
||||
if rv.typed[tid] != "movie" {
|
||||
t.Errorf("SetType получил %q", rv.typed[tid])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,10 +675,10 @@ func TestUndoAndDefer(t *testing.T) {
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
cl := noRedirectClient()
|
||||
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/1/undo", "", nil); err != nil {
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/undo", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/1/defer", "", nil); err != nil {
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/defer", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rv.undone) != 1 || len(rv.deferred) != 1 {
|
||||
@@ -682,11 +692,11 @@ func TestRelink(t *testing.T) {
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
cl := noRedirectClient()
|
||||
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/1/relink", "", nil); err != nil {
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/relink", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rv.relinked) != 1 || rv.relinked[0] != 1 {
|
||||
t.Errorf("relinked = %v, want [1]", rv.relinked)
|
||||
if len(rv.relinked) != 1 || rv.relinked[0] != tid {
|
||||
t.Errorf("relinked = %v, want [%s]", rv.relinked, tid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,10 +706,10 @@ func TestRerecognize(t *testing.T) {
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
cl := noRedirectClient()
|
||||
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/1/rerecognize", "", nil); err != nil {
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/rerecognize", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rv.rerecognized) != 1 || rv.rerecognized[0] != 1 {
|
||||
t.Errorf("rerecognized = %v, want [1]", rv.rerecognized)
|
||||
if len(rv.rerecognized) != 1 || rv.rerecognized[0] != tid {
|
||||
t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false }
|
||||
// /progress). Active управляется store-состоянием (downloading), а не qbt:
|
||||
// когда задача покидает downloading, фрагмент возвращается без поллинга.
|
||||
type progressView struct {
|
||||
ID int64
|
||||
ID string
|
||||
Active bool // store-состояние downloading → показываем бар и поллим
|
||||
Has bool // есть данные снимка
|
||||
Percent int
|
||||
@@ -39,7 +39,7 @@ type progressView struct {
|
||||
// Has истинно только если торрент сидирует и данные есть — иначе секция
|
||||
// деградирует (пустой контейнер, поллинг прекращается).
|
||||
type seedingView struct {
|
||||
ID int64
|
||||
ID string
|
||||
Has bool
|
||||
Percent int
|
||||
Ratio string
|
||||
@@ -49,7 +49,7 @@ type seedingView struct {
|
||||
UpSpeed string
|
||||
}
|
||||
|
||||
func buildProgress(id int64, active bool, l worker.Live, ok bool) progressView {
|
||||
func buildProgress(id string, active bool, l worker.Live, ok bool) progressView {
|
||||
v := progressView{ID: id, Active: active}
|
||||
if ok {
|
||||
v.Has = true
|
||||
@@ -64,7 +64,7 @@ func buildProgress(id int64, active bool, l worker.Live, ok bool) progressView {
|
||||
return v
|
||||
}
|
||||
|
||||
func buildSeeding(id int64, l worker.Live, ok bool) seedingView {
|
||||
func buildSeeding(id string, l worker.Live, ok bool) seedingView {
|
||||
v := seedingView{ID: id}
|
||||
if ok && l.Seeding {
|
||||
v.Has = true
|
||||
@@ -82,7 +82,7 @@ func buildSeeding(id int64, l worker.Live, ok bool) seedingView {
|
||||
func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "некорректный id", http.StatusBadRequest)
|
||||
http.Error(w, "не найдено", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||
@@ -91,7 +91,7 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
active := d.State == store.StateDownloading
|
||||
l, ok := s.deps.Live.Live(d.Infohash.String)
|
||||
l, ok := s.liveFor(*d)
|
||||
s.render(w, "progress", buildProgress(id, active, l, ok))
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "некорректный id", http.StatusBadRequest)
|
||||
http.Error(w, "не найдено", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||
@@ -107,13 +107,13 @@ func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
|
||||
s.fragErr(w, err, id)
|
||||
return
|
||||
}
|
||||
l, ok := s.deps.Live.Live(d.Infohash.String)
|
||||
l, ok := s.liveFor(*d)
|
||||
s.render(w, "seeding", buildSeeding(id, l, ok))
|
||||
}
|
||||
|
||||
// fragErr транслирует ошибку чтения задачи для фрагмент-роутов: ErrNotFound →
|
||||
// 404, прочее → 500 (полная ошибка уже залогирована на доменной границе).
|
||||
func (s *server) fragErr(w http.ResponseWriter, err error, id int64) {
|
||||
func (s *server) fragErr(w http.ResponseWriter, err error, id string) {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, "не найдено", http.StatusNotFound)
|
||||
return
|
||||
|
||||
@@ -12,16 +12,16 @@ import (
|
||||
// TestFragProgressDownloading: активная задача → фрагмент с прогрессом,
|
||||
// значениями снимка и атрибутами htmx-поллинга.
|
||||
func TestFragProgressDownloading(t *testing.T) {
|
||||
dl := store.Download{ID: 5, Infohash: store.NullString("ih5"), State: store.StateDownloading}
|
||||
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDownloading}
|
||||
lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
|
||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv)
|
||||
|
||||
rr := get(t, h, "/fragments/downloads/5/progress")
|
||||
rr := get(t, h, "/fragments/downloads/"+testULID+"/progress")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{`hx-trigger="every 3s"`, "/fragments/downloads/5/progress", "width:42%", "42%"} {
|
||||
for _, want := range []string{`hx-trigger="every 3s"`, "/fragments/downloads/" + testULID + "/progress", "width:42%", "42%"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body)
|
||||
}
|
||||
@@ -31,10 +31,10 @@ func TestFragProgressDownloading(t *testing.T) {
|
||||
// TestFragProgressStopsWhenNotDownloading: когда задача покинула downloading,
|
||||
// фрагмент отдаётся без атрибутов поллинга (поллинг прекращается).
|
||||
func TestFragProgressStopsWhenNotDownloading(t *testing.T) {
|
||||
dl := store.Download{ID: 5, Infohash: store.NullString("ih5"), State: store.StateDone}
|
||||
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDone}
|
||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
|
||||
|
||||
rr := get(t, h, "/fragments/downloads/5/progress")
|
||||
rr := get(t, h, "/fragments/downloads/"+testULID+"/progress")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
@@ -46,13 +46,13 @@ func TestFragProgressStopsWhenNotDownloading(t *testing.T) {
|
||||
// TestFragSeeding: сидирующая задача → секция «Раздача» со статистикой и
|
||||
// поллингом.
|
||||
func TestFragSeeding(t *testing.T) {
|
||||
dl := store.Download{ID: 9, Infohash: store.NullString("ih9"), State: store.StateDone}
|
||||
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih9", Kind: store.HashV1}}, State: store.StateDone}
|
||||
lv := stubLive{m: map[string]worker.Live{"ih9": {
|
||||
Seeding: true, Progress: 1, Ratio: 2.41, Seeds: 38, Peers: 14, Uploaded: 1 << 30, UpSpeed: 1153433,
|
||||
}}}
|
||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv)
|
||||
|
||||
rr := get(t, h, "/fragments/downloads/9/seeding")
|
||||
rr := get(t, h, "/fragments/downloads/"+testULID+"/seeding")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
@@ -66,10 +66,10 @@ func TestFragSeeding(t *testing.T) {
|
||||
|
||||
// TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет.
|
||||
func TestFragSeedingDegrades(t *testing.T) {
|
||||
dl := store.Download{ID: 9, Infohash: store.NullString("ih9"), State: store.StateDone}
|
||||
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih9", Kind: store.HashV1}}, State: store.StateDone}
|
||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
|
||||
|
||||
rr := get(t, h, "/fragments/downloads/9/seeding")
|
||||
rr := get(t, h, "/fragments/downloads/"+testULID+"/seeding")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func TestFragSeedingDegrades(t *testing.T) {
|
||||
// TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже
|
||||
// в первом кадре (значения снимка) и атрибуты поллинга.
|
||||
func TestIndexCardShowsLiveProgress(t *testing.T) {
|
||||
dl := store.Download{ID: 3, SourceRef: "The.Bear.S03", Infohash: store.NullString("ih3"), State: store.StateDownloading}
|
||||
dl := store.Download{ID: testULID, SourceRef: "The.Bear.S03", Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih3", Kind: store.HashV1}}, State: store.StateDownloading}
|
||||
lv := stubLive{m: map[string]worker.Live{"ih3": {Progress: 0.46, DlSpeed: 6400000, ETA: 720}}}
|
||||
h := testRouterLive(t, stubReader{list: []store.Download{dl}}, stubReviewer{}, lv)
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestIndexCardShowsLiveProgress(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/3/progress"} {
|
||||
for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/" + testULID + "/progress"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("карточка без живого прогресса: нет %q", want)
|
||||
}
|
||||
@@ -101,7 +101,11 @@ func TestIndexCardShowsLiveProgress(t *testing.T) {
|
||||
// TestFragNotFound: фрагмент несуществующей задачи → 404.
|
||||
func TestFragNotFound(t *testing.T) {
|
||||
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
|
||||
if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound {
|
||||
if rr := get(t, h, "/fragments/downloads/01arz3ndektsv4rrffq69g5fff/progress"); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
// Невалидный id → 404 без похода в БД.
|
||||
if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status(invalid id) = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
return s.list, len(s.list), nil
|
||||
}
|
||||
func (s stubReader) GetDownload(context.Context, int64) (*store.Download, error) {
|
||||
func (s stubReader) GetDownload(context.Context, string) (*store.Download, error) {
|
||||
if s.one == nil {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
@@ -33,23 +33,23 @@ func (s stubReader) GetDownload(context.Context, int64) (*store.Download, error)
|
||||
// stubReviewer — Reviewer-заглушка (нужна для /download/{id}).
|
||||
type stubReviewer struct{ data *worker.ReviewData }
|
||||
|
||||
func (s stubReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) {
|
||||
func (s stubReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
|
||||
if s.data == nil {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return s.data, nil
|
||||
}
|
||||
func (stubReviewer) Apply(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) Refine(context.Context, int64, string) error { return nil }
|
||||
func (stubReviewer) SetType(context.Context, int64, string) error { return nil }
|
||||
func (stubReviewer) IgnoreFile(context.Context, int64, string) error { return nil }
|
||||
func (stubReviewer) Defer(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) Undo(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) Relink(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) Rerecognize(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) ChooseCandidate(context.Context, int64, int64) error { return nil }
|
||||
func (stubReviewer) SetProviderID(context.Context, int64, string, string) error { return nil }
|
||||
func (stubReviewer) ClearProvider(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) Apply(context.Context, string) error { return nil }
|
||||
func (stubReviewer) Refine(context.Context, string, string) error { return nil }
|
||||
func (stubReviewer) SetType(context.Context, string, string) error { return nil }
|
||||
func (stubReviewer) IgnoreFile(context.Context, string, string) error { return nil }
|
||||
func (stubReviewer) Defer(context.Context, string) error { return nil }
|
||||
func (stubReviewer) Undo(context.Context, string) error { return nil }
|
||||
func (stubReviewer) Relink(context.Context, string) error { return nil }
|
||||
func (stubReviewer) Rerecognize(context.Context, string) error { return nil }
|
||||
func (stubReviewer) ChooseCandidate(context.Context, string, string) error { return nil }
|
||||
func (stubReviewer) SetProviderID(context.Context, string, string, string) error { return nil }
|
||||
func (stubReviewer) ClearProvider(context.Context, string) error { return nil }
|
||||
|
||||
// stubLive — заглушка источника живой телеметрии.
|
||||
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
|
||||
}
|
||||
|
||||
// testULID — валидный lowercase-ULID для маршрутов (pathID валидирует формат).
|
||||
const testULID = "01arz3ndektsv4rrffq69g5fav"
|
||||
|
||||
// TestRouterRendersPages проверяет, что шаблоны парсятся и страницы рендерятся.
|
||||
func TestRouterRendersPages(t *testing.T) {
|
||||
dl := store.Download{ID: 7, SourceRef: "Fargo.S02", Infohash: store.NullString("a1b2c3d4e5f6a7b8"), State: store.StateReview}
|
||||
dl := store.Download{ID: testULID, SourceRef: "Fargo.S02",
|
||||
Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "a1b2c3d4e5f6a7b8", Kind: store.HashV1}},
|
||||
State: store.StateReview}
|
||||
h := testRouter(t,
|
||||
stubReader{list: []store.Download{dl}, one: &dl},
|
||||
stubReviewer{data: &worker.ReviewData{Download: dl}},
|
||||
@@ -99,8 +104,8 @@ func TestRouterRendersPages(t *testing.T) {
|
||||
t.Errorf("index не содержит бейдж статуса")
|
||||
}
|
||||
|
||||
if rr := get(t, h, "/download/7"); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /download/7 = %d, want 200", rr.Code)
|
||||
if rr := get(t, h, "/download/"+testULID); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /download/{id} = %d, want 200", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +127,27 @@ func TestStaticServed(t *testing.T) {
|
||||
// TestDownloadNotFound — несуществующая загрузка → 404.
|
||||
func TestDownloadNotFound(t *testing.T) {
|
||||
h := testRouter(t, stubReader{}, stubReviewer{})
|
||||
if rr := get(t, h, "/download/999"); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /download/999 = %d, want 404", rr.Code)
|
||||
if rr := get(t, h, "/download/01arz3ndektsv4rrffq69g5fff"); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /download/{missing} = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadInvalidID — синтаксически невалидный id → 404 без похода в БД.
|
||||
func TestDownloadInvalidID(t *testing.T) {
|
||||
h := testRouter(t, stubReader{}, stubReviewer{})
|
||||
for _, path := range []string{"/download/999", "/download/abc!!!", "/review/12"} {
|
||||
if rr := get(t, h, path); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET %s = %d, want 404 (невалидный id = несуществующая сущность)", path, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadUppercaseIDNormalized — uppercase-вариант id ведёт на ту же
|
||||
// страницу (нормализация на входной границе).
|
||||
func TestDownloadUppercaseIDNormalized(t *testing.T) {
|
||||
dl := store.Download{ID: testULID, SourceRef: "X", State: store.StateReview}
|
||||
h := testRouter(t, stubReader{one: &dl}, stubReviewer{data: &worker.ReviewData{Download: dl}})
|
||||
if rr := get(t, h, "/download/"+strings.ToUpper(testULID)); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /download/{UPPERCASE} = %d, want 200", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
+29
-26
@@ -7,30 +7,31 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
|
||||
// Reviewer — операции ревью и раскладки (worker.Worker).
|
||||
type Reviewer interface {
|
||||
ReviewData(ctx context.Context, id int64) (*worker.ReviewData, error)
|
||||
Apply(ctx context.Context, id int64) error
|
||||
Refine(ctx context.Context, id int64, hint string) error
|
||||
SetType(ctx context.Context, id int64, mediaType string) error
|
||||
IgnoreFile(ctx context.Context, id int64, src string) error
|
||||
Defer(ctx context.Context, id int64) error
|
||||
Undo(ctx context.Context, id int64) error
|
||||
Relink(ctx context.Context, id int64) error
|
||||
Rerecognize(ctx context.Context, id int64) error
|
||||
ChooseCandidate(ctx context.Context, id, candidateID int64) error
|
||||
SetProviderID(ctx context.Context, id int64, provider, providerID string) error
|
||||
ClearProvider(ctx context.Context, id int64) error
|
||||
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
|
||||
Apply(ctx context.Context, id string) error
|
||||
Refine(ctx context.Context, id string, hint string) error
|
||||
SetType(ctx context.Context, id string, mediaType string) error
|
||||
IgnoreFile(ctx context.Context, id string, src string) error
|
||||
Defer(ctx context.Context, id string) error
|
||||
Undo(ctx context.Context, id string) error
|
||||
Relink(ctx context.Context, id string) error
|
||||
Rerecognize(ctx context.Context, id string) error
|
||||
ChooseCandidate(ctx context.Context, id, candidateID string) error
|
||||
SetProviderID(ctx context.Context, id string, provider, providerID string) error
|
||||
ClearProvider(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// --- Представление страницы ревью ---
|
||||
|
||||
type reviewView struct {
|
||||
ID int64
|
||||
ID string
|
||||
Source string
|
||||
Context string
|
||||
State string
|
||||
@@ -55,7 +56,7 @@ type reviewView struct {
|
||||
}
|
||||
|
||||
type candidateView struct {
|
||||
ID int64
|
||||
ID string
|
||||
Provider string
|
||||
ProviderID string
|
||||
Title string
|
||||
@@ -67,7 +68,8 @@ type candidateView struct {
|
||||
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "некорректный id", http.StatusBadRequest)
|
||||
// Невалидный id = несуществующая сущность; в БД не ходим.
|
||||
http.Error(w, "задача не найдена", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
@@ -145,36 +147,37 @@ func (s *server) handleApply(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *server) handleRefine(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
_ = r.ParseForm()
|
||||
return s.deps.Reviewer.Refine(ctx, id, r.PostForm.Get("hint"))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleRerecognize(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
return s.deps.Reviewer.Rerecognize(ctx, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
_ = r.ParseForm()
|
||||
return s.deps.Reviewer.SetType(ctx, id, r.PostForm.Get("type"))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
_ = r.ParseForm()
|
||||
return s.deps.Reviewer.IgnoreFile(ctx, id, r.PostForm.Get("src"))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
_ = r.ParseForm()
|
||||
candidateID, err := strconv.ParseInt(r.PostForm.Get("candidate_id"), 10, 64)
|
||||
// Входная граница: id кандидата из формы валидируется как ULID.
|
||||
candidateID, err := ident.Parse(r.PostForm.Get("candidate_id"))
|
||||
if err != nil {
|
||||
return errInvalidCandidate
|
||||
}
|
||||
@@ -183,14 +186,14 @@ func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
_ = r.ParseForm()
|
||||
return s.deps.Reviewer.SetProviderID(ctx, id, r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
return s.deps.Reviewer.ClearProvider(ctx, id)
|
||||
})
|
||||
}
|
||||
@@ -240,7 +243,7 @@ func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// reviewAction — общий помощник: выполнить действие и вернуться на страницу
|
||||
// ревью (с ошибкой в ?err при неудаче).
|
||||
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) error) {
|
||||
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
redirectErr(w, r, "некорректный id")
|
||||
@@ -296,8 +299,8 @@ func providerURL(provider, id, mediaType string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func redirectReview(w http.ResponseWriter, r *http.Request, id int64, msg string) {
|
||||
u := "/review/" + strconv.FormatInt(id, 10)
|
||||
func redirectReview(w http.ResponseWriter, r *http.Request, id string, msg string) {
|
||||
u := "/review/" + id
|
||||
if msg != "" {
|
||||
u += "?err=" + url.QueryEscape(msg)
|
||||
}
|
||||
|
||||
@@ -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 часть хранилища.
|
||||
type Store interface {
|
||||
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
|
||||
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
|
||||
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
|
||||
// FindActiveByInfohash — быстрый читающий дедуп-чек (до вызова LLM-namer);
|
||||
// авторитетная проверка — внутри CreateDownloadIfNoActive.
|
||||
FindActiveByInfohash(ctx context.Context, hashes ...string) (*store.Download, error)
|
||||
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
|
||||
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
|
||||
// дедуп на активную задачу (недостающие хеши вызова метод доносит сам).
|
||||
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error)
|
||||
// AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на
|
||||
// быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive.
|
||||
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
||||
SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
||||
}
|
||||
|
||||
// QBittorrent — нужная ingest часть клиента qBittorrent.
|
||||
@@ -58,7 +66,7 @@ type Service struct {
|
||||
// не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не
|
||||
// попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest
|
||||
// сам; closure избавляет ядро приёма от зависимости на пакет worker.
|
||||
notifyFailed func(downloadID int64)
|
||||
notifyFailed func(downloadID string)
|
||||
}
|
||||
|
||||
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не
|
||||
@@ -68,7 +76,7 @@ func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *S
|
||||
}
|
||||
|
||||
// SetFailureNotifier подключает пинг о падении приёма (до начала работы).
|
||||
func (s *Service) SetFailureNotifier(fn func(downloadID int64)) { s.notifyFailed = fn }
|
||||
func (s *Service) SetFailureNotifier(fn func(downloadID string)) { s.notifyFailed = fn }
|
||||
|
||||
// Request — входной запрос приёма.
|
||||
type Request struct {
|
||||
@@ -78,8 +86,8 @@ type Request struct {
|
||||
|
||||
// Result — итог приёма.
|
||||
type Result struct {
|
||||
DownloadID int64
|
||||
Infohash string
|
||||
DownloadID string
|
||||
Infohashes []string // все хеши источника (гибридный magnet: v1 и v2, v1 первым)
|
||||
State store.State
|
||||
Deduplicated bool // присоединились к уже активной задаче, нового добавления не было
|
||||
}
|
||||
@@ -100,16 +108,14 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
|
||||
ctx = logctx.With(ctx, log)
|
||||
|
||||
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohash); err != nil {
|
||||
// Быстрый дедуп-чек до дорогого LLM-namer; авторитетная (атомарная)
|
||||
// проверка — внутри CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из
|
||||
// хешей источника: гибридный magnet несёт и v1, и v2.
|
||||
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohashes...); err != nil {
|
||||
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
||||
} else if existing != nil {
|
||||
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||||
return Result{
|
||||
DownloadID: existing.ID,
|
||||
Infohash: info.Infohash,
|
||||
State: existing.State,
|
||||
Deduplicated: true,
|
||||
}, nil
|
||||
return s.attached(ctx, info, existing), nil
|
||||
}
|
||||
|
||||
// Отображаемое имя для списка qBit — best-effort: не валит приём.
|
||||
@@ -122,18 +128,30 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
}
|
||||
|
||||
d := &store.Download{
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: source,
|
||||
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
|
||||
Context: req.Context,
|
||||
Infohash: store.NullString(info.Infohash),
|
||||
IdempotencyKey: store.NullString(info.Infohash),
|
||||
State: store.StateDownloading,
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: source,
|
||||
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
|
||||
Context: req.Context,
|
||||
State: store.StateDownloading,
|
||||
}
|
||||
id, err := s.store.CreateDownload(ctx, d)
|
||||
// Все хеши из magnet (гибридный несёт v1 и v2); kind store выведет по длине.
|
||||
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, info.Infohashes)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("ingest: create download: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
// Гонка с параллельным приёмом/discover: активная задача появилась
|
||||
// после быстрого чека — присоединяемся к ней (хеши донёс сам
|
||||
// CreateDownloadIfNoActive).
|
||||
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||||
return Result{
|
||||
DownloadID: existing.ID,
|
||||
Infohashes: info.Infohashes,
|
||||
State: existing.State,
|
||||
Deduplicated: true,
|
||||
}, nil
|
||||
}
|
||||
id := d.ID
|
||||
log = log.With("download_id", id)
|
||||
ctx = logctx.With(ctx, log)
|
||||
|
||||
@@ -156,14 +174,33 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
// сами, чтобы приёмные провалы тоже доходили до автора.
|
||||
go s.notifyFailed(id)
|
||||
}
|
||||
return Result{DownloadID: id, Infohash: info.Infohash, State: store.StateFailed},
|
||||
return Result{DownloadID: id, Infohashes: info.Infohashes, State: store.StateFailed},
|
||||
fmt.Errorf("ingest: add to qbittorrent: %w", addErr)
|
||||
}
|
||||
|
||||
log.Info("download accepted", "category", s.cfg.Category)
|
||||
return Result{
|
||||
DownloadID: id,
|
||||
Infohash: info.Infohash,
|
||||
Infohashes: info.Infohashes,
|
||||
State: store.StateDownloading,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// attached — итог дедупа на быстром чеке: присоединились к уже активной
|
||||
// задаче и доносим ей недостающие хеши источника (гибридный magnet мог
|
||||
// принести хеш, которого задача ещё не знает; guarded-путь через
|
||||
// CreateDownloadIfNoActive сюда не доходит). Донос — best-effort: конфликт
|
||||
// хеша с другой активной задачей логируется, приём не валится.
|
||||
func (s *Service) attached(ctx context.Context, info magnet.Info, existing *store.Download) Result {
|
||||
if len(info.Infohashes) > len(existing.Infohashes) {
|
||||
if err := s.store.AddInfohashes(ctx, existing.ID, info.Infohashes); err != nil {
|
||||
logctx.FromOr(ctx, s.log).Warn("ingest top-up infohashes failed", "error", err)
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
DownloadID: existing.ID,
|
||||
Infohashes: info.Infohashes,
|
||||
State: existing.State,
|
||||
Deduplicated: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
@@ -19,29 +20,39 @@ const sampleInfohash = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
type fakeStore struct {
|
||||
active *store.Download
|
||||
created []store.Download
|
||||
nextID int64
|
||||
hashes [][]string
|
||||
toppedUp []string
|
||||
stateCalls []stateCall
|
||||
}
|
||||
|
||||
type stateCall struct {
|
||||
id int64
|
||||
id string
|
||||
state store.State
|
||||
code string
|
||||
msg string
|
||||
}
|
||||
|
||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ string) (*store.Download, error) {
|
||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
|
||||
return f.active, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
|
||||
f.nextID++
|
||||
d.ID = f.nextID
|
||||
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
||||
if f.active != nil {
|
||||
return f.active, nil
|
||||
}
|
||||
d.ID = ident.NewID()
|
||||
f.created = append(f.created, *d)
|
||||
return f.nextID, nil
|
||||
f.hashes = append(f.hashes, hashes)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
|
||||
func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string) error {
|
||||
f.toppedUp = append(f.toppedUp, hashes...)
|
||||
_ = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
|
||||
f.stateCalls = append(f.stateCalls, stateCall{id, st, code, msg})
|
||||
return nil
|
||||
}
|
||||
@@ -90,8 +101,8 @@ func TestIngestHappyPath(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if res.Infohash != sampleInfohash {
|
||||
t.Errorf("infohash = %q", res.Infohash)
|
||||
if len(res.Infohashes) != 1 || res.Infohashes[0] != sampleInfohash {
|
||||
t.Errorf("infohashes = %v", res.Infohashes)
|
||||
}
|
||||
if res.State != store.StateDownloading || res.Deduplicated {
|
||||
t.Errorf("res = %+v", res)
|
||||
@@ -99,9 +110,12 @@ func TestIngestHappyPath(t *testing.T) {
|
||||
if len(fs.created) != 1 {
|
||||
t.Fatalf("создано задач: %d, want 1", len(fs.created))
|
||||
}
|
||||
if got := fs.created[0]; got.Context != "Дюна 2" || got.Infohash.String != sampleInfohash {
|
||||
if got := fs.created[0]; got.Context != "Дюна 2" {
|
||||
t.Errorf("сохранённая задача: %+v", got)
|
||||
}
|
||||
if len(fs.hashes) != 1 || len(fs.hashes[0]) != 1 || fs.hashes[0][0] != sampleInfohash {
|
||||
t.Errorf("хеши задачи: %v", fs.hashes)
|
||||
}
|
||||
if len(fq.added) != 1 {
|
||||
t.Fatalf("вызовов qbt.Add: %d, want 1", len(fq.added))
|
||||
}
|
||||
@@ -149,15 +163,15 @@ func TestIngestEmptyNameOmitsRename(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIngestIdempotent(t *testing.T) {
|
||||
existing := &store.Download{ID: 7, State: store.StateDownloading}
|
||||
existing := &store.Download{ID: "01hzzzexisting000000000000", State: store.StateDownloading}
|
||||
fs := &fakeStore{active: existing}
|
||||
fq := &fakeQbt{}
|
||||
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if !res.Deduplicated || res.DownloadID != 7 {
|
||||
t.Errorf("ожидалось присоединение к задаче 7: %+v", res)
|
||||
if !res.Deduplicated || res.DownloadID != existing.ID {
|
||||
t.Errorf("ожидалось присоединение к существующей задаче: %+v", res)
|
||||
}
|
||||
if len(fs.created) != 0 {
|
||||
t.Error("не должно создаваться новой задачи")
|
||||
@@ -167,6 +181,29 @@ func TestIngestIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Быстрый дедуп-путь доносит существующей задаче недостающие хеши
|
||||
// гибридного magnet (иначе последующий приём по второму хешу создал бы
|
||||
// вторую активную задачу).
|
||||
func TestIngestDedupTopsUpHashes(t *testing.T) {
|
||||
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
existing := &store.Download{
|
||||
ID: "01hzzzexisting000000000000", State: store.StateDownloading,
|
||||
Infohashes: []store.Infohash{{DownloadID: "01hzzzexisting000000000000", Infohash: sampleInfohash, Kind: store.HashV1}},
|
||||
}
|
||||
fs := &fakeStore{active: existing}
|
||||
res, err := newService(fs, &fakeQbt{}).Ingest(context.Background(),
|
||||
Request{Source: sampleMagnet + "&xt=urn:btmh:1220" + v2})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if !res.Deduplicated {
|
||||
t.Fatalf("ожидался дедуп: %+v", res)
|
||||
}
|
||||
if len(fs.toppedUp) != 2 {
|
||||
t.Errorf("хеши не донесены существующей задаче: %v", fs.toppedUp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestQbitErrorMarksFailed(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{err: errors.New("connection refused")}
|
||||
@@ -186,16 +223,16 @@ func TestIngestQbitErrorNotifies(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{err: errors.New("connection refused")}
|
||||
svc := newService(fs, fq)
|
||||
got := make(chan int64, 1)
|
||||
svc.SetFailureNotifier(func(id int64) { got <- id })
|
||||
got := make(chan string, 1)
|
||||
svc.SetFailureNotifier(func(id string) { got <- id })
|
||||
|
||||
if _, err := svc.Ingest(context.Background(), Request{Source: sampleMagnet}); err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
select {
|
||||
case id := <-got:
|
||||
if id == 0 {
|
||||
t.Errorf("уведомление с нулевым id")
|
||||
if id == "" {
|
||||
t.Errorf("уведомление с пустым id")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("уведомление о падении приёма не пришло")
|
||||
|
||||
@@ -16,7 +16,8 @@ import (
|
||||
|
||||
// Info — разобранная magnet-ссылка.
|
||||
type Info struct {
|
||||
Infohash string // нормализованный нижний hex (40 для v1, 64 для v2)
|
||||
Infohash string // первичный хеш: v1 приоритетно (нижний hex, 40 для v1, 64 для v2)
|
||||
Infohashes []string // все хеши ссылки (гибридный magnet несёт btih и btmh); v1 раньше v2
|
||||
DisplayName string // dn — человекочитаемое имя, если задано
|
||||
Trackers []string // tr — трекеры
|
||||
}
|
||||
@@ -49,16 +50,19 @@ func Parse(raw string) (Info, error) {
|
||||
}
|
||||
}
|
||||
|
||||
infohash := v1
|
||||
if infohash == "" {
|
||||
infohash = v2
|
||||
var hashes []string
|
||||
for _, h := range []string{v1, v2} {
|
||||
if h != "" {
|
||||
hashes = append(hashes, h)
|
||||
}
|
||||
}
|
||||
if infohash == "" {
|
||||
if len(hashes) == 0 {
|
||||
return Info{}, fmt.Errorf("magnet without a usable infohash (xt)")
|
||||
}
|
||||
|
||||
return Info{
|
||||
Infohash: infohash,
|
||||
Infohash: hashes[0],
|
||||
Infohashes: hashes,
|
||||
DisplayName: vals.Get("dn"),
|
||||
Trackers: vals["tr"],
|
||||
}, nil
|
||||
|
||||
@@ -47,6 +47,24 @@ func TestParse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Гибридный magnet несёт оба хеша: Infohash — v1 (приоритет), Infohashes — оба.
|
||||
func TestParseHybridKeepsBothHashes(t *testing.T) {
|
||||
const (
|
||||
v1 = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
)
|
||||
got, err := Parse("magnet:?xt=urn:btih:" + v1 + "&xt=urn:btmh:1220" + v2)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if got.Infohash != v1 {
|
||||
t.Errorf("primary = %q, want v1", got.Infohash)
|
||||
}
|
||||
if len(got.Infohashes) != 2 || got.Infohashes[0] != v1 || got.Infohashes[1] != v2 {
|
||||
t.Errorf("infohashes = %v, want [v1 v2]", got.Infohashes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseErrors(t *testing.T) {
|
||||
cases := []string{
|
||||
"https://example.com/file.torrent", // не magnet
|
||||
|
||||
+382
-78
@@ -8,6 +8,10 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
)
|
||||
|
||||
// State — состояние загрузки в машине состояний (см. architecture.md).
|
||||
@@ -35,27 +39,26 @@ const (
|
||||
)
|
||||
|
||||
// terminalStates — единый список окончательно остановленных состояний:
|
||||
// источник истины и для IsTerminal, и для выборки «активных» задач
|
||||
// (FindActiveByInfohash). Любое новое терминальное состояние добавляется
|
||||
// ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key
|
||||
// снимается по IsTerminal, а активность считалась бы по другому списку).
|
||||
// источник истины и для IsTerminal, и для выборки «активных» задач.
|
||||
// Активность выводится ТОЛЬКО из state (отдельного ключа идемпотентности
|
||||
// нет); любое новое терминальное состояние добавляется ТОЛЬКО сюда — иначе
|
||||
// семантика «активности» разъедется.
|
||||
//
|
||||
// Состояния рассинхрона (target_missing/orphaned/deleted), а также
|
||||
// failed/stuck — терминальны для idempotency_key, но не «мертвы»: дальше
|
||||
// двигает либо человек (relink из target_missing, retry из failed/stuck), либо
|
||||
// фоновая сверка (healing/прогрессия desync; авто-восстановление failed/stuck
|
||||
// при оживлении источника, см. state-reconciliation) — напрямую через
|
||||
// SetDownloadState, который восстановит ключ для нетерминального целевого
|
||||
// состояния.
|
||||
// Состояния рассинхрона (target_missing/orphaned/deleted), а также failed —
|
||||
// терминальны для активности, но не «мертвы»: дальше двигает либо человек
|
||||
// (relink из target_missing, retry из failed), либо фоновая сверка (healing/
|
||||
// прогрессия desync; авто-восстановление при оживлении источника, см.
|
||||
// state-reconciliation) — через ActivateIfNoOtherActive, который атомарно
|
||||
// проверяет инвариант «не более одной активной загрузки на infohash».
|
||||
var terminalStates = []State{
|
||||
StateDone, StateCancelled, StateFailed, StateReverted,
|
||||
StateTargetMissing, StateOrphaned, StateDeleted,
|
||||
}
|
||||
|
||||
// IsTerminal сообщает, завершена ли задача окончательно. Для терминальных
|
||||
// состояний снимается ключ идемпотентности — тот же infohash можно завести
|
||||
// заново новой задачей (см. architecture.md, «повторное добавление»).
|
||||
// stuck терминальным не считается: задача восстановима (retry).
|
||||
// IsTerminal сообщает, завершена ли задача окончательно. Терминальная задача
|
||||
// не «владеет» своими инфохэшами — тот же infohash можно завести заново
|
||||
// новой задачей (см. architecture.md, «повторное добавление»). stuck
|
||||
// терминальным не считается: задача восстановима (retry).
|
||||
func (s State) IsTerminal() bool {
|
||||
return slices.Contains(terminalStates, s)
|
||||
}
|
||||
@@ -69,18 +72,44 @@ const (
|
||||
SourceURL SourceType = "url"
|
||||
)
|
||||
|
||||
// Виды инфохэша (download_infohash.kind).
|
||||
const (
|
||||
HashV1 = "v1" // SHA-1, 40 hex
|
||||
HashV2 = "v2" // SHA-256, 64 hex
|
||||
)
|
||||
|
||||
// HashKind — вид инфохэша по длине hex-строки: 64 — v2, иначе v1.
|
||||
func HashKind(h string) string {
|
||||
if len(h) == 64 {
|
||||
return HashV2
|
||||
}
|
||||
return HashV1
|
||||
}
|
||||
|
||||
// NormalizeHash приводит инфохэш к канонической форме хранения (нижний hex —
|
||||
// в этом же виде его отдаёт qBittorrent).
|
||||
func NormalizeHash(h string) string {
|
||||
return strings.ToLower(strings.TrimSpace(h))
|
||||
}
|
||||
|
||||
// Infohash — строка таблицы download_infohash: один из хешей загрузки
|
||||
// (у одной загрузки их несколько: v1/v2 гибридного торрента).
|
||||
type Infohash struct {
|
||||
DownloadID string `db:"download_id"`
|
||||
Infohash string `db:"infohash"`
|
||||
Kind string `db:"kind"`
|
||||
}
|
||||
|
||||
// Download — строка таблицы download.
|
||||
type Download struct {
|
||||
ID int64 `db:"id"`
|
||||
SourceType SourceType `db:"source_type"`
|
||||
SourceRef string `db:"source_ref"`
|
||||
DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI
|
||||
Context string `db:"context"`
|
||||
Infohash sql.NullString `db:"infohash"`
|
||||
IdempotencyKey sql.NullString `db:"idempotency_key"`
|
||||
State State `db:"state"`
|
||||
ErrorCode sql.NullString `db:"error_code"`
|
||||
ErrorMsg sql.NullString `db:"error_msg"`
|
||||
ID string `db:"id"` // ULID (lowercase), публичный ключ домена
|
||||
SourceType SourceType `db:"source_type"`
|
||||
SourceRef string `db:"source_ref"`
|
||||
DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI
|
||||
Context string `db:"context"`
|
||||
State State `db:"state"`
|
||||
ErrorCode sql.NullString `db:"error_code"`
|
||||
ErrorMsg sql.NullString `db:"error_msg"`
|
||||
// SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в
|
||||
// qBittorrent (дебаунс пропажи источника, см. state-reconciliation).
|
||||
SourceMissCount int `db:"source_miss_count"`
|
||||
@@ -91,12 +120,34 @@ type Download struct {
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
|
||||
// Infohashes — хеши загрузки (download_infohash); подгружаются вместе с
|
||||
// записью методами чтения store (v1 раньше v2 — порядок стабильный).
|
||||
Infohashes []Infohash `db:"-"`
|
||||
|
||||
// RecTitle — распознанное название текущей попытки (LEFT JOIN recognition).
|
||||
// Заполняется только листингом ListDownloadsPage для фолбека заголовка; в
|
||||
// прочих выборках остаётся пустым.
|
||||
RecTitle sql.NullString `db:"rec_title"`
|
||||
}
|
||||
|
||||
// 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).
|
||||
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 != ""}
|
||||
}
|
||||
|
||||
// CreateDownload вставляет загрузку и возвращает её id.
|
||||
func (s *Store) CreateDownload(ctx context.Context, d *Download) (int64, error) {
|
||||
// CreateDownloadIfNoActive атомарно (одна write-транзакция, BEGIN IMMEDIATE
|
||||
// через _txlock) проверяет инвариант «не более одной активной загрузки на
|
||||
// infohash» и заводит загрузку: если активная задача с любым из hashes уже
|
||||
// есть — возвращает её (дедуп, ничего не создавая); иначе вставляет d с новым
|
||||
// ULID и его хешами и возвращает (nil, nil). d.ID и d.Infohashes заполняются.
|
||||
func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashes []string) (*Download, error) {
|
||||
norm := normalizeHashes(hashes)
|
||||
if len(norm) == 0 {
|
||||
return nil, fmt.Errorf("create download: no infohash")
|
||||
}
|
||||
|
||||
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 = `
|
||||
INSERT INTO download (source_type, source_ref, display_name, context, infohash, idempotency_key, state)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
res, err := s.DB.ExecContext(ctx, q,
|
||||
d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.Infohash, d.IdempotencyKey, d.State)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert download: %w", err)
|
||||
INSERT INTO download (id, source_type, source_ref, display_name, context, state)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
d.ID, d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.State); err != nil {
|
||||
return nil, fmt.Errorf("insert download: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("download last insert id: %w", err)
|
||||
d.Infohashes = d.Infohashes[:0]
|
||||
for _, h := range norm {
|
||||
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.
|
||||
func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
|
||||
// ActivateIfNoOtherActive атомарно возвращает загрузку в активное состояние
|
||||
// (retry/восстановление сверкой/relink): в одной write-транзакции проверяет,
|
||||
// что никакая ДРУГАЯ активная загрузка не владеет любым из хешей этой, и
|
||||
// переводит состояние. При владении возвращает ErrInfohashTaken (обёрнутый
|
||||
// с id владельца).
|
||||
func (s *Store) ActivateIfNoOtherActive(ctx context.Context, id string, state State, errCode, errMsg string) error {
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("activate %s: begin tx: %w", id, err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var hashes []string
|
||||
if err := tx.SelectContext(ctx, &hashes,
|
||||
`SELECT infohash FROM download_infohash WHERE download_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("activate %s: read hashes: %w", id, err)
|
||||
}
|
||||
if len(hashes) > 0 {
|
||||
// Исключаем саму задачу: при retry из stuck она сама активна и без
|
||||
// исключения LIMIT 1 мог бы вернуть её, замаскировав другого владельца.
|
||||
other, err := findActiveByInfohash(ctx, tx, hashes, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("activate %s: %w", id, err)
|
||||
}
|
||||
if other != nil {
|
||||
return fmt.Errorf("activate %s: infohash owned by download %s: %w",
|
||||
id, other.ID, ErrInfohashTaken)
|
||||
}
|
||||
}
|
||||
if err := setState(ctx, tx, id, state, errCode, errMsg, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("activate %s: commit: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddInfohashes дописывает загрузке недостающие хеши (qBittorrent раскрыл
|
||||
// оба хеша гибридного торрента, а приём знал один). Это тоже мутация
|
||||
// владения хешем, поэтому она под тем же гардом, что и create/activate:
|
||||
// в одной write-транзакции каждый хеш проверяется на владение ДРУГОЙ
|
||||
// активной задачей; конфликтные хеши не дописываются, метод возвращает
|
||||
// ErrInfohashTaken (неконфликтные при этом дописаны — частичный успех).
|
||||
func (s *Store) AddInfohashes(ctx context.Context, downloadID string, hashes []string) error {
|
||||
norm := normalizeHashes(hashes)
|
||||
if len(norm) == 0 {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
if err := s.DB.GetContext(ctx, &d, `SELECT * FROM download WHERE id = ?`, id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("get download %d: %w", id, ErrNotFound)
|
||||
return nil, fmt.Errorf("get download %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("get download %d: %w", id, err)
|
||||
return nil, fmt.Errorf("get download %s: %w", id, err)
|
||||
}
|
||||
if err := attachInfohashesOne(ctx, s.DB, &d); err != nil {
|
||||
return nil, fmt.Errorf("get download %s: %w", id, err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
@@ -153,20 +336,24 @@ func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
|
||||
// SetSourceAddedAt однократно фиксирует время добавления торрента в источник
|
||||
// (qBittorrent added_on). SQL-гард `source_added_at IS NULL` не даёт перезапи-
|
||||
// сать значение на повторных наблюдениях: время добавления неизменно.
|
||||
func (s *Store) SetSourceAddedAt(ctx context.Context, id int64, t time.Time) error {
|
||||
func (s *Store) SetSourceAddedAt(ctx context.Context, id string, t time.Time) error {
|
||||
const q = `UPDATE download SET source_added_at = ? WHERE id = ? AND source_added_at IS NULL`
|
||||
if _, err := s.DB.ExecContext(ctx, q, FormatTime(t), id); err != nil {
|
||||
return fmt.Errorf("set source added at %d: %w", id, err)
|
||||
return fmt.Errorf("set source added at %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListDownloads возвращает все загрузки, новые сверху.
|
||||
// ListDownloads возвращает все загрузки, новые сверху (id — ULID, сортировка
|
||||
// по нему хронологична).
|
||||
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
|
||||
var out []Download
|
||||
if err := s.DB.SelectContext(ctx, &out, `SELECT * FROM download ORDER BY id DESC`); err != nil {
|
||||
return nil, fmt.Errorf("list downloads: %w", err)
|
||||
}
|
||||
if err := s.attachInfohashes(ctx, out); err != nil {
|
||||
return nil, fmt.Errorf("list downloads: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -186,6 +373,9 @@ func (s *Store) ListDownloadsByState(ctx context.Context, states ...State) ([]Do
|
||||
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list downloads by state: %w", err)
|
||||
}
|
||||
if err := s.attachInfohashes(ctx, out); err != nil {
|
||||
return nil, fmt.Errorf("list downloads by state: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -210,25 +400,52 @@ func (s *Store) ListRecoverable(ctx context.Context, codes ...string) ([]Downloa
|
||||
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list recoverable: %w", err)
|
||||
}
|
||||
if err := s.attachInfohashes(ctx, out); err != nil {
|
||||
return nil, fmt.Errorf("list recoverable: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FindActiveByInfohash возвращает незавершённую задачу для infohash либо
|
||||
// (nil, nil), если её нет. Основа идемпотентного приёма.
|
||||
func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) {
|
||||
// «Активна» = не в терминальном состоянии. Список — единый с IsTerminal
|
||||
// (terminalStates), иначе семантика активности разъедется с idempotency_key.
|
||||
ph := make([]string, len(terminalStates))
|
||||
args := make([]any, 0, len(terminalStates)+1)
|
||||
args = append(args, infohash)
|
||||
for i, st := range terminalStates {
|
||||
ph[i] = "?"
|
||||
args = append(args, string(st))
|
||||
// FindActiveByInfohash возвращает незавершённую задачу, владеющую любым из
|
||||
// hashes, либо (nil, nil). Читающая основа дедупа; сам инвариант держат
|
||||
// guarded-методы (CreateDownloadIfNoActive / ActivateIfNoOtherActive).
|
||||
func (s *Store) FindActiveByInfohash(ctx context.Context, hashes ...string) (*Download, error) {
|
||||
d, err := findActiveByInfohash(ctx, s.DB, normalizeHashes(hashes), "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := `SELECT * FROM download WHERE infohash = ? AND state NOT IN (` +
|
||||
strings.Join(ph, ",") + `) ORDER BY id DESC LIMIT 1`
|
||||
if d != nil {
|
||||
if err := attachInfohashesOne(ctx, s.DB, d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// findActiveByInfohash — общая выборка «активная задача по любому из хешей»
|
||||
// (для guarded-методов — внутри их транзакции). hashes уже нормализованы;
|
||||
// excludeID исключает саму проверяемую задачу (она может быть активной,
|
||||
// например stuck при retry, — и не должна маскировать другого владельца);
|
||||
// пустой excludeID — без исключения. Хеши найденной загрузки НЕ подгружаются.
|
||||
func findActiveByInfohash(ctx context.Context, q sqlx.QueryerContext, hashes []string, excludeID string) (*Download, error) {
|
||||
if len(hashes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// «Активна» = не в терминальном состоянии. Список — единый с IsTerminal
|
||||
// (terminalStates), иначе семантика активности разъедется.
|
||||
var args []any
|
||||
hashPh := placeholders(&args, hashes)
|
||||
statePh := placeholders(&args, terminalStates)
|
||||
query := `SELECT download.* FROM download
|
||||
JOIN download_infohash dh ON dh.download_id = download.id
|
||||
WHERE dh.infohash IN (` + hashPh + `) AND download.state NOT IN (` + statePh + `)`
|
||||
if excludeID != "" {
|
||||
query += ` AND download.id != ?`
|
||||
args = append(args, excludeID)
|
||||
}
|
||||
query += ` ORDER BY download.id DESC LIMIT 1`
|
||||
var d Download
|
||||
err := s.DB.GetContext(ctx, &d, q, args...)
|
||||
err := sqlx.GetContext(ctx, q, &d, query, args...)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -238,63 +455,150 @@ func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Dow
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// placeholders дописывает значения in в args и возвращает строку "?,?,…"
|
||||
// той же длины — сборка IN-списков без ручного жонглирования срезами.
|
||||
func placeholders[T ~string](args *[]any, in []T) string {
|
||||
ph := make([]string, len(in))
|
||||
for i, v := range in {
|
||||
ph[i] = "?"
|
||||
*args = append(*args, string(v))
|
||||
}
|
||||
return strings.Join(ph, ",")
|
||||
}
|
||||
|
||||
// ExistsByInfohash сообщает, есть ли хоть одна загрузка (в любом состоянии)
|
||||
// с данным infohash. Discovery усыновляет раздачу только если её ещё не
|
||||
// с любым из hashes. Discovery усыновляет раздачу только если её ещё не
|
||||
// видели — так готовые задачи не переобрабатываются на каждом тике.
|
||||
func (s *Store) ExistsByInfohash(ctx context.Context, infohash string) (bool, error) {
|
||||
func (s *Store) ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error) {
|
||||
norm := normalizeHashes(hashes)
|
||||
if len(norm) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
ph := make([]string, len(norm))
|
||||
args := make([]any, len(norm))
|
||||
for i, h := range norm {
|
||||
ph[i] = "?"
|
||||
args[i] = h
|
||||
}
|
||||
var n int
|
||||
if err := s.DB.GetContext(ctx, &n,
|
||||
`SELECT COUNT(1) FROM download WHERE infohash = ?`, infohash); err != nil {
|
||||
`SELECT COUNT(1) FROM download_infohash WHERE infohash IN (`+strings.Join(ph, ",")+`)`,
|
||||
args...); err != nil {
|
||||
return false, fmt.Errorf("exists by infohash: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// SetDownloadState переводит загрузку в новое состояние. Ключ
|
||||
// идемпотентности пересчитывается из текущего infohash: для терминального
|
||||
// состояния снимается (NULL), иначе равен infohash — так partial unique
|
||||
// index гарантирует не более одной активной задачи на infohash.
|
||||
func (s *Store) SetDownloadState(ctx context.Context, id int64, state State, errCode, errMsg string) error {
|
||||
const q = `
|
||||
// SetDownloadState переводит загрузку в новое состояние. Механический
|
||||
// бэкстоп инварианта «одна активная загрузка на infohash» (заменяет
|
||||
// удалённый unique-индекс): переход из терминального состояния в активное
|
||||
// этим методом отклоняется — возврат в активное идёт ТОЛЬКО через
|
||||
// ActivateIfNoOtherActive, который проверяет владение хешами.
|
||||
func (s *Store) SetDownloadState(ctx context.Context, id string, state State, errCode, errMsg string) error {
|
||||
return setState(ctx, s.DB, id, state, errCode, errMsg, false)
|
||||
}
|
||||
|
||||
// setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда
|
||||
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
|
||||
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу.
|
||||
func setState(ctx context.Context, e sqlx.ExecerContext, id string, state State, errCode, errMsg string, reviveOK bool) error {
|
||||
q := `
|
||||
UPDATE download
|
||||
SET state = ?,
|
||||
error_code = ?,
|
||||
error_msg = ?,
|
||||
idempotency_key = CASE WHEN ? = 1 THEN NULL ELSE infohash END,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
terminal := 0
|
||||
if state.IsTerminal() {
|
||||
terminal = 1
|
||||
args := []any{string(state), nullArg(errCode), nullArg(errMsg), id}
|
||||
if !reviveOK && !state.IsTerminal() {
|
||||
q += ` AND state NOT IN (` + placeholders(&args, terminalStates) + `)`
|
||||
}
|
||||
res, err := s.DB.ExecContext(ctx, q, string(state), nullArg(errCode), nullArg(errMsg), terminal, id)
|
||||
res, err := e.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set download %d state %q: %w", id, state, err)
|
||||
return fmt.Errorf("set download %s state %q: %w", id, state, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("set download %d state %q: %w", id, state, err)
|
||||
return fmt.Errorf("set download %s state %q: %w", id, state, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("set download %d state %q: not found", id, state)
|
||||
return fmt.Errorf("set download %s state %q: not found or terminal (revive requires ActivateIfNoOtherActive)", id, state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSourceMissCount записывает счётчик пропусков источника (дебаунс сверки).
|
||||
// Состояние не трогает — это отдельная от перехода фоновая отметка.
|
||||
func (s *Store) SetSourceMissCount(ctx context.Context, id int64, n int) error {
|
||||
func (s *Store) SetSourceMissCount(ctx context.Context, id string, n int) error {
|
||||
res, err := s.DB.ExecContext(ctx,
|
||||
`UPDATE download SET source_miss_count = ? WHERE id = ?`, n, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set download %d source_miss_count: %w", id, err)
|
||||
return fmt.Errorf("set download %s source_miss_count: %w", id, err)
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
return fmt.Errorf("set download %d source_miss_count: not found", id)
|
||||
return fmt.Errorf("set download %s source_miss_count: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// attachInfohashes подгружает хеши для набора загрузок батч-запросами
|
||||
// (порядок стабильный: v1 раньше v2). IN-список режется на чанки, чтобы
|
||||
// безлимитные выборки (ListDownloads за всю историю) не упирались в
|
||||
// SQLITE_MAX_VARIABLE_NUMBER.
|
||||
func (s *Store) attachInfohashes(ctx context.Context, ds []Download) error {
|
||||
if len(ds) == 0 {
|
||||
return nil
|
||||
}
|
||||
const chunkSize = 500
|
||||
byID := make(map[string][]Infohash, len(ds))
|
||||
for start := 0; start < len(ds); start += chunkSize {
|
||||
end := min(start+chunkSize, len(ds))
|
||||
var args []any
|
||||
ids := make([]string, 0, end-start)
|
||||
for i := start; i < end; i++ {
|
||||
ids = append(ids, ds[i].ID)
|
||||
}
|
||||
ph := placeholders(&args, ids)
|
||||
var rows []Infohash
|
||||
if err := s.DB.SelectContext(ctx, &rows,
|
||||
`SELECT download_id, infohash, kind FROM download_infohash
|
||||
WHERE download_id IN (`+ph+`) ORDER BY kind, infohash`,
|
||||
args...); err != nil {
|
||||
return fmt.Errorf("attach infohashes: %w", err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
byID[r.DownloadID] = append(byID[r.DownloadID], r)
|
||||
}
|
||||
}
|
||||
for i := range ds {
|
||||
ds[i].Infohashes = byID[ds[i].ID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// attachInfohashesOne подгружает хеши одной загрузки (в т.ч. внутри tx).
|
||||
func attachInfohashesOne(ctx context.Context, q sqlx.QueryerContext, d *Download) error {
|
||||
if err := sqlx.SelectContext(ctx, q, &d.Infohashes,
|
||||
`SELECT download_id, infohash, kind FROM download_infohash
|
||||
WHERE download_id = ? ORDER BY kind, infohash`, d.ID); err != nil {
|
||||
return fmt.Errorf("attach infohashes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeHashes нормализует и дедуплицирует хеши, отбрасывая пустые.
|
||||
func normalizeHashes(hashes []string) []string {
|
||||
out := make([]string, 0, len(hashes))
|
||||
for _, h := range hashes {
|
||||
h = NormalizeHash(h)
|
||||
if h == "" || slices.Contains(out, h) {
|
||||
continue
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nullArg возвращает nil для пустой строки (чтобы писать NULL, не "").
|
||||
func nullArg(s string) any {
|
||||
if s == "" {
|
||||
|
||||
+317
-41
@@ -2,6 +2,9 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -15,24 +18,39 @@ func newTestStore(t *testing.T) *Store {
|
||||
return st
|
||||
}
|
||||
|
||||
func newDownloading(infohash string) *Download {
|
||||
func newDownloading() *Download {
|
||||
return &Download{
|
||||
SourceType: SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
||||
Context: "ctx",
|
||||
Infohash: NullString(infohash),
|
||||
IdempotencyKey: NullString(infohash),
|
||||
State: StateDownloading,
|
||||
SourceType: SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:test",
|
||||
Context: "ctx",
|
||||
State: StateDownloading,
|
||||
}
|
||||
}
|
||||
|
||||
// mustCreate заводит загрузку с хешем и возвращает её id; дедуп на
|
||||
// существующую активную — ошибка теста.
|
||||
func mustCreate(t *testing.T, st *Store, infohash string) string {
|
||||
t.Helper()
|
||||
d := newDownloading()
|
||||
existing, err := st.CreateDownloadIfNoActive(context.Background(), d, []string{infohash})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if existing != nil {
|
||||
t.Fatalf("unexpected dedup to %s", existing.ID)
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
|
||||
func TestCreateAndGetDownload(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := st.CreateDownload(ctx, newDownloading("aabbccddeeff00112233445566778899aabbccdd"))
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
const ih = "AABBCCDDEEFF00112233445566778899aabbccdd" // смешанный регистр — нормализуется
|
||||
id := mustCreate(t, st, ih)
|
||||
|
||||
if len(id) != 26 || id != strings.ToLower(id) {
|
||||
t.Errorf("id = %q, want lowercase ULID (26 chars)", id)
|
||||
}
|
||||
|
||||
got, err := st.GetDownload(ctx, id)
|
||||
@@ -48,6 +66,12 @@ func TestCreateAndGetDownload(t *testing.T) {
|
||||
if got.CreatedAt == "" {
|
||||
t.Error("created_at пуст")
|
||||
}
|
||||
if len(got.Infohashes) != 1 {
|
||||
t.Fatalf("infohashes = %v, want 1", got.Infohashes)
|
||||
}
|
||||
if h := got.Infohashes[0]; h.Infohash != strings.ToLower(ih) || h.Kind != HashV1 {
|
||||
t.Errorf("infohash = %+v, want lowercase v1", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindActiveByInfohash(t *testing.T) {
|
||||
@@ -59,10 +83,7 @@ func TestFindActiveByInfohash(t *testing.T) {
|
||||
t.Fatalf("ожидался (nil,nil), получили (%v,%v)", d, err)
|
||||
}
|
||||
|
||||
id, err := st.CreateDownload(ctx, newDownloading(ih))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := mustCreate(t, st, ih)
|
||||
d, err := st.FindActiveByInfohash(ctx, ih)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -70,6 +91,9 @@ func TestFindActiveByInfohash(t *testing.T) {
|
||||
if d == nil || d.ID != id {
|
||||
t.Fatalf("активная задача не найдена: %v", d)
|
||||
}
|
||||
if len(d.Infohashes) != 1 {
|
||||
t.Fatalf("хеши не подгружены: %v", d.Infohashes)
|
||||
}
|
||||
}
|
||||
|
||||
// Состояния рассинхрона (target_missing/orphaned/deleted) терминальны: задача
|
||||
@@ -81,12 +105,9 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
|
||||
t.Run(string(st), func(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
ih := "33333333333333333333333333333333333333" + string(st[0:2])
|
||||
const ih = "3333333333333333333333333333333333333333"
|
||||
|
||||
id, err := store.CreateDownload(ctx, newDownloading(ih))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := mustCreate(t, store, ih)
|
||||
if err := store.SetDownloadState(ctx, id, st, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -97,17 +118,15 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Терминальное состояние снимает ключ идемпотентности и позволяет завести
|
||||
// тот же infohash заново (повторная закачка спустя время).
|
||||
// Терминальное состояние освобождает infohash: тот же хеш заводится заново
|
||||
// новой задачей (повторная закачка спустя время) — активность выводится
|
||||
// только из state.
|
||||
func TestTerminalReleasesInfohash(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const ih = "2222222222222222222222222222222222222222"
|
||||
|
||||
id, err := st.CreateDownload(ctx, newDownloading(ih))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := mustCreate(t, st, ih)
|
||||
if err := st.SetDownloadState(ctx, id, StateFailed, "qbit_add", "boom"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -116,39 +135,291 @@ func TestTerminalReleasesInfohash(t *testing.T) {
|
||||
if d, err := st.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
|
||||
t.Fatalf("после failed активная задача не должна находиться: (%v,%v)", d, err)
|
||||
}
|
||||
// Ключ идемпотентности снят.
|
||||
got, err := st.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.IdempotencyKey.Valid {
|
||||
t.Errorf("idempotency_key должен быть NULL, получили %q", got.IdempotencyKey.String)
|
||||
}
|
||||
if got.ErrorCode.String != "qbit_add" {
|
||||
t.Errorf("error_code = %q", got.ErrorCode.String)
|
||||
}
|
||||
|
||||
// Тот же infohash заводится заново — unique index не мешает.
|
||||
id2, err := st.CreateDownload(ctx, newDownloading(ih))
|
||||
if err != nil {
|
||||
t.Fatalf("повторное добавление после терминального должно проходить: %v", err)
|
||||
}
|
||||
// Тот же infohash заводится заново новой задачей.
|
||||
id2 := mustCreate(t, st, ih)
|
||||
if id2 == id {
|
||||
t.Error("ожидалась новая задача")
|
||||
}
|
||||
}
|
||||
|
||||
// Две активные задачи с одним ключом идемпотентности недопустимы.
|
||||
func TestActiveDuplicateRejected(t *testing.T) {
|
||||
// Повторный приём при активной задаче дедуплицируется: вторая вставка не
|
||||
// создаёт строку, а возвращает существующую активную задачу.
|
||||
func TestActiveDuplicateDeduplicated(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const ih = "3333333333333333333333333333333333333333"
|
||||
|
||||
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err != nil {
|
||||
id := mustCreate(t, st, ih)
|
||||
|
||||
d := newDownloading()
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err == nil {
|
||||
t.Error("ожидалось нарушение уникальности idempotency_key")
|
||||
if existing == nil || existing.ID != id {
|
||||
t.Fatalf("ожидался дедуп на %s, получили %v", id, existing)
|
||||
}
|
||||
if len(existing.Infohashes) != 1 {
|
||||
t.Fatalf("у существующей задачи не подгружены хеши: %v", existing.Infohashes)
|
||||
}
|
||||
}
|
||||
|
||||
// Дедуп ловит совпадение по ЛЮБОМУ из хешей: активная задача знает v1+v2,
|
||||
// новый приём приходит только с v2.
|
||||
func TestDedupByAnyHash(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const v1 = "4444444444444444444444444444444444444444"
|
||||
const v2 = "6666666666666666666666666666666666666666666666666666666666666666"
|
||||
|
||||
id := mustCreate(t, st, v1)
|
||||
if err := st.AddInfohashes(ctx, id, []string{v2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if existing == nil || existing.ID != id {
|
||||
t.Fatalf("ожидался дедуп по v2-хешу на %s, получили %v", id, existing)
|
||||
}
|
||||
|
||||
// Хеши задачи: v1 раньше v2 (стабильный порядок), повторное добавление
|
||||
// идемпотентно.
|
||||
if err := st.AddInfohashes(ctx, id, []string{v2, v1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Infohashes) != 2 || got.Infohashes[0].Kind != HashV1 || got.Infohashes[1].Kind != HashV2 {
|
||||
t.Fatalf("infohashes = %+v, want [v1, v2]", got.Infohashes)
|
||||
}
|
||||
if got.PrimaryInfohash() != v1 {
|
||||
t.Errorf("primary = %q, want v1", got.PrimaryInfohash())
|
||||
}
|
||||
}
|
||||
|
||||
// ActivateIfNoOtherActive отказывает, когда хешем владеет другая активная
|
||||
// задача, и пропускает, когда владелец ушёл в терминал.
|
||||
func TestActivateIfNoOtherActive(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const ih = "5555555555555555555555555555555555555555"
|
||||
|
||||
id1 := mustCreate(t, st, ih)
|
||||
if err := st.SetDownloadState(ctx, id1, StateFailed, "magnet_timeout", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Хеш перехватила новая активная задача.
|
||||
id2 := mustCreate(t, st, ih)
|
||||
|
||||
err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", "")
|
||||
if !errors.Is(err, ErrInfohashTaken) {
|
||||
t.Fatalf("ожидался ErrInfohashTaken, получили %v", err)
|
||||
}
|
||||
if d, _ := st.GetDownload(ctx, id1); d.State != StateFailed {
|
||||
t.Fatalf("задача не должна была активироваться: %s", d.State)
|
||||
}
|
||||
|
||||
// Владелец завершился → активация проходит.
|
||||
if err := st.SetDownloadState(ctx, id2, StateDone, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", ""); err != nil {
|
||||
t.Fatalf("активация после ухода владельца: %v", err)
|
||||
}
|
||||
if d, _ := st.GetDownload(ctx, id1); d.State != StateDownloading {
|
||||
t.Fatalf("state = %s, want downloading", d.State)
|
||||
}
|
||||
}
|
||||
|
||||
// Дедуп-ветка дописывает существующей задаче недостающие хеши гибридного
|
||||
// вызова — второй хеш не теряется, и последующий приём по нему дедупится.
|
||||
func TestCreateDedupTopsUpHashes(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const v1 = "aaaa111111111111111111111111111111111111"
|
||||
const v2 = "bbbb222222222222222222222222222222222222222222222222222222222222"
|
||||
|
||||
id := mustCreate(t, st, v1)
|
||||
|
||||
// Гибридный вызов с {v1, v2} дедупится на задачу и доносит ей v2.
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if existing == nil || existing.ID != id {
|
||||
t.Fatalf("ожидался дедуп на %s, получили %v", id, existing)
|
||||
}
|
||||
if len(existing.Infohashes) != 2 {
|
||||
t.Fatalf("хеши existing = %+v, want v1+v2 (top-up)", existing.Infohashes)
|
||||
}
|
||||
// Теперь приём только по v2 тоже дедупится, а не создаёт вторую задачу.
|
||||
byV2, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if byV2 == nil || byV2.ID != id {
|
||||
t.Fatalf("дедуп по донесённому v2 не сработал: %v", byV2)
|
||||
}
|
||||
}
|
||||
|
||||
// AddInfohashes под гардом: хеш, которым владеет другая активная задача,
|
||||
// не дописывается — возвращается ErrInfohashTaken.
|
||||
func TestAddInfohashesGuard(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const h1 = "cccc111111111111111111111111111111111111"
|
||||
const h2 = "dddd222222222222222222222222222222222222"
|
||||
|
||||
a := mustCreate(t, st, h1)
|
||||
_ = mustCreate(t, st, h2) // активный владелец h2
|
||||
|
||||
err := st.AddInfohashes(ctx, a, []string{h2})
|
||||
if !errors.Is(err, ErrInfohashTaken) {
|
||||
t.Fatalf("ожидался ErrInfohashTaken, получили %v", err)
|
||||
}
|
||||
got, _ := st.GetDownload(ctx, a)
|
||||
if len(got.Infohashes) != 1 || got.Infohashes[0].Infohash != h1 {
|
||||
t.Fatalf("чужой хеш не должен был дописаться: %+v", got.Infohashes)
|
||||
}
|
||||
|
||||
// Хеш терминального владельца дописывается свободно.
|
||||
if err := st.SetDownloadState(ctx, a, StateDone, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := mustCreate(t, st, "eeee333333333333333333333333333333333333")
|
||||
if err := st.AddInfohashes(ctx, b, []string{h1}); err != nil {
|
||||
t.Fatalf("хеш терминальной задачи должен дописываться: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Гард активации не маскирует конфликт самой активируемой задачей: stuck
|
||||
// (нетерминальна) с более новым id не должна перекрыть старшего владельца.
|
||||
func TestActivateExcludesSelf(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const h = "ffff111111111111111111111111111111111111"
|
||||
|
||||
older := mustCreate(t, st, h) // активный владелец, id старше
|
||||
newer := mustCreate(t, st, "0000222222222222222222222222222222222222")
|
||||
if err := st.SetDownloadState(ctx, newer, StateStuck, "stalled", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Легаси/аварийное состояние: у newer тот же хеш h (мимо API — гард
|
||||
// такого не создаст, но обязан не маскировать).
|
||||
if _, err := st.DB.ExecContext(ctx,
|
||||
`INSERT INTO download_infohash (download_id, infohash, kind) 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)
|
||||
ctx := context.Background()
|
||||
|
||||
id1, _ := st.CreateDownload(ctx, newDownloading("4444444444444444444444444444444444444444"))
|
||||
id2, _ := st.CreateDownload(ctx, newDownloading("5555555555555555555555555555555555555555"))
|
||||
id1 := mustCreate(t, st, "4444444444444444444444444444444444444444")
|
||||
id2 := mustCreate(t, st, "5555555555555555555555555555555555555555")
|
||||
if err := st.SetDownloadState(ctx, id2, StateCompleted, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -169,6 +440,11 @@ func TestListAndByState(t *testing.T) {
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("ListDownloads = %d, want 2", len(all))
|
||||
}
|
||||
for _, d := range all {
|
||||
if len(d.Infohashes) != 1 {
|
||||
t.Fatalf("у %s не подгружены хеши", d.ID)
|
||||
}
|
||||
}
|
||||
|
||||
dl, err := st.ListDownloadsByState(ctx, StateDownloading)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,3 +6,9 @@ import "errors"
|
||||
// в него sql.ErrNoRows у источника, чтобы выше по коду не торчал database/sql,
|
||||
// а потребители матчили причину через errors.Is(err, store.ErrNotFound).
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// ErrInfohashTaken — инвариант «не более одной активной загрузки на infohash»:
|
||||
// возврат задачи в активное состояние отклонён, потому что хешем уже владеет
|
||||
// другая активная задача (ActivateIfNoOtherActive). Вызывающие отличают этот
|
||||
// штатный конфликт от сбоя через errors.Is.
|
||||
var ErrInfohashTaken = errors.New("infohash owned by another active download")
|
||||
|
||||
@@ -74,7 +74,8 @@ func listWhere(f ListFilter) (string, []any) {
|
||||
conds = append(conds, "(source_ref LIKE ? COLLATE NOCASE "+
|
||||
"OR display_name 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)
|
||||
}
|
||||
|
||||
@@ -109,5 +110,8 @@ LIMIT ? OFFSET ?`
|
||||
if err := s.DB.SelectContext(ctx, &out, q, pageArgs...); err != nil {
|
||||
return nil, 0, fmt.Errorf("list downloads page: %w", err)
|
||||
}
|
||||
if err := s.attachInfohashes(ctx, out); err != nil {
|
||||
return nil, 0, fmt.Errorf("list downloads page: %w", err)
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
+15
-12
@@ -11,25 +11,28 @@ import (
|
||||
func hashN(n int) string { return fmt.Sprintf("%040x", n) }
|
||||
|
||||
// mkDownload заводит загрузку в заданном состоянии с display_name.
|
||||
func mkDownload(t *testing.T, st *Store, n int, state State, display string) int64 {
|
||||
func mkDownload(t *testing.T, st *Store, n int, state State, display string) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
d := newDownloading(hashN(n))
|
||||
d := newDownloading()
|
||||
d.DisplayName = display
|
||||
id, err := st.CreateDownload(ctx, d)
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(n)})
|
||||
if err != nil {
|
||||
t.Fatalf("create #%d: %v", n, err)
|
||||
}
|
||||
if existing != nil {
|
||||
t.Fatalf("create #%d: unexpected dedup", n)
|
||||
}
|
||||
if state != StateDownloading {
|
||||
if err := st.SetDownloadState(ctx, id, state, "", ""); err != nil {
|
||||
if err := st.SetDownloadState(ctx, d.ID, state, "", ""); err != nil {
|
||||
t.Fatalf("set state #%d: %v", n, err)
|
||||
}
|
||||
}
|
||||
return id
|
||||
return d.ID
|
||||
}
|
||||
|
||||
func ids(ds []Download) []int64 {
|
||||
out := make([]int64, len(ds))
|
||||
func ids(ds []Download) []string {
|
||||
out := make([]string, len(ds))
|
||||
for i, d := range ds {
|
||||
out[i] = d.ID
|
||||
}
|
||||
@@ -51,7 +54,7 @@ func TestListDownloadsPageFilterAndDeleted(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(page) != 1 || page[0].ID != review {
|
||||
t.Fatalf("review group = %v (total %d), want [%d]", ids(page), total, review)
|
||||
t.Fatalf("review group = %v (total %d), want [%s]", ids(page), total, review)
|
||||
}
|
||||
|
||||
// all: deleted скрыт по умолчанию.
|
||||
@@ -110,7 +113,7 @@ func TestListDownloadsPageSearch(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page) != 1 || page[0].Infohash.String != hashN(2) {
|
||||
if len(page) != 1 || page[0].PrimaryInfohash() != hashN(2) {
|
||||
t.Fatalf("search by infohash = %v", ids(page))
|
||||
}
|
||||
}
|
||||
@@ -144,7 +147,7 @@ func TestListDownloadsPageOrderAndPagination(t *testing.T) {
|
||||
// id3 фолбечит на created_at (~сейчас, 2026-07-01) — свежее, чем добавления
|
||||
// id1/id2 в июне → id3 первым; затем id2 (позже добавлен), затем id1.
|
||||
got := ids(page)
|
||||
want := []int64{id3, id2, id1}
|
||||
want := []string{id3, id2, id1}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("порядок = %v, want %v", got, want)
|
||||
@@ -178,7 +181,7 @@ func TestListDownloadsPageTieBreakByID(t *testing.T) {
|
||||
|
||||
// Одинаковое время добавления у всех → устойчивый порядок по id DESC.
|
||||
same := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
var idList []int64
|
||||
var idList []string
|
||||
for i := 1; i <= 3; i++ {
|
||||
id := mkDownload(t, st, i, StateDownloading, fmt.Sprintf("d%d", i))
|
||||
if err := st.SetSourceAddedAt(ctx, id, same); err != nil {
|
||||
@@ -191,7 +194,7 @@ func TestListDownloadsPageTieBreakByID(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ids(page)
|
||||
want := []int64{idList[2], idList[1], idList[0]} // id DESC
|
||||
want := []string{idList[2], idList[1], idList[0]} // id DESC
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("tie-break порядок = %v, want %v", got, want)
|
||||
|
||||
@@ -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"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
)
|
||||
|
||||
// Recognition — строка таблицы recognition (попытка распознавания).
|
||||
type Recognition struct {
|
||||
ID int64 `db:"id"`
|
||||
DownloadID int64 `db:"download_id"`
|
||||
ID string `db:"id"`
|
||||
DownloadID string `db:"download_id"`
|
||||
AttemptNo int `db:"attempt_no"`
|
||||
IsCurrent bool `db:"is_current"`
|
||||
MediaType sql.NullString `db:"media_type"`
|
||||
@@ -41,54 +43,50 @@ func (r Recognition) ReasonList() []string {
|
||||
// CreateRecognition вставляет новую попытку распознавания, помечая прежние
|
||||
// как неактуальные (is_current = 0) и проставляя следующий attempt_no.
|
||||
// Возвращает id новой записи. reasons сериализуется в JSON.
|
||||
func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons []string) (int64, error) {
|
||||
func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons []string) (string, error) {
|
||||
reasonsJSON, err := json.Marshal(reasons)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal reasons: %w", err)
|
||||
return "", fmt.Errorf("marshal reasons: %w", err)
|
||||
}
|
||||
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin tx: %w", err)
|
||||
return "", fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE recognition SET is_current = 0 WHERE download_id = ?`, r.DownloadID); err != nil {
|
||||
return 0, fmt.Errorf("clear current recognitions: %w", err)
|
||||
return "", fmt.Errorf("clear current recognitions: %w", err)
|
||||
}
|
||||
|
||||
var nextAttempt int
|
||||
if err := tx.GetContext(ctx, &nextAttempt,
|
||||
`SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM recognition WHERE download_id = ?`,
|
||||
r.DownloadID); err != nil {
|
||||
return 0, fmt.Errorf("next attempt_no: %w", err)
|
||||
return "", fmt.Errorf("next attempt_no: %w", err)
|
||||
}
|
||||
|
||||
r.ID = ident.NewID()
|
||||
const q = `
|
||||
INSERT INTO recognition
|
||||
(download_id, attempt_no, is_current, media_type, title, original_title,
|
||||
(id, download_id, attempt_no, is_current, media_type, title, original_title,
|
||||
year, provider, provider_id, confidence, reasons, raw_llm, plan)
|
||||
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
res, err := tx.ExecContext(ctx, q,
|
||||
r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
|
||||
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert recognition: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("recognition last insert id: %w", err)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
|
||||
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan); err != nil {
|
||||
return "", fmt.Errorf("insert recognition: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("commit recognition: %w", err)
|
||||
return "", fmt.Errorf("commit recognition: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
return r.ID, nil
|
||||
}
|
||||
|
||||
// GetCurrentRecognition возвращает актуальную попытку распознавания загрузки
|
||||
// либо (nil, nil), если её ещё нет.
|
||||
func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID int64) (*Recognition, error) {
|
||||
func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID string) (*Recognition, error) {
|
||||
var r Recognition
|
||||
err := s.DB.GetContext(ctx, &r,
|
||||
`SELECT * FROM recognition WHERE download_id = ? AND is_current = 1
|
||||
@@ -105,16 +103,17 @@ func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID int64) (*R
|
||||
// --- Подсказки (hint) ---
|
||||
|
||||
// AddHint добавляет текстовую подсказку ревьюера к загрузке.
|
||||
func (s *Store) AddHint(ctx context.Context, downloadID int64, text string) error {
|
||||
func (s *Store) AddHint(ctx context.Context, downloadID string, text string) error {
|
||||
if _, err := s.DB.ExecContext(ctx,
|
||||
`INSERT INTO hint (download_id, text) VALUES (?, ?)`, downloadID, text); err != nil {
|
||||
`INSERT INTO hint (id, download_id, text) VALUES (?, ?, ?)`,
|
||||
ident.NewID(), downloadID, text); err != nil {
|
||||
return fmt.Errorf("add hint: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListHints возвращает подсказки загрузки в хронологическом порядке.
|
||||
func (s *Store) ListHints(ctx context.Context, downloadID int64) ([]string, error) {
|
||||
func (s *Store) ListHints(ctx context.Context, downloadID string) ([]string, error) {
|
||||
var out []string
|
||||
if err := s.DB.SelectContext(ctx, &out,
|
||||
`SELECT text FROM hint WHERE download_id = ? ORDER BY id`, downloadID); err != nil {
|
||||
@@ -126,18 +125,18 @@ func (s *Store) ListHints(ctx context.Context, downloadID int64) ([]string, erro
|
||||
// --- Ручные правки (override) ---
|
||||
|
||||
// SetOverride пиннит значение поля (upsert по (download_id, field)).
|
||||
func (s *Store) SetOverride(ctx context.Context, downloadID int64, field, value string) error {
|
||||
func (s *Store) SetOverride(ctx context.Context, downloadID string, field, value string) error {
|
||||
const q = `
|
||||
INSERT INTO override (download_id, field, value) VALUES (?, ?, ?)
|
||||
INSERT INTO override (id, download_id, field, value) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (download_id, field) DO UPDATE SET value = excluded.value`
|
||||
if _, err := s.DB.ExecContext(ctx, q, downloadID, field, value); err != nil {
|
||||
if _, err := s.DB.ExecContext(ctx, q, ident.NewID(), downloadID, field, value); err != nil {
|
||||
return fmt.Errorf("set override %q: %w", field, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListOverrides возвращает запиненные правки загрузки как map[field]value.
|
||||
func (s *Store) ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error) {
|
||||
func (s *Store) ListOverrides(ctx context.Context, downloadID string) (map[string]string, error) {
|
||||
rows, err := s.DB.QueryxContext(ctx,
|
||||
`SELECT field, value FROM override WHERE download_id = ?`, downloadID)
|
||||
if err != nil {
|
||||
@@ -160,8 +159,8 @@ func (s *Store) ListOverrides(ctx context.Context, downloadID int64) (map[string
|
||||
|
||||
// FileLink — строка таблицы file_link (одна созданная/планируемая ссылка).
|
||||
type FileLink struct {
|
||||
ID int64 `db:"id"`
|
||||
DownloadID int64 `db:"download_id"`
|
||||
ID string `db:"id"`
|
||||
DownloadID string `db:"download_id"`
|
||||
ApplyBatchID string `db:"apply_batch_id"`
|
||||
SrcPath string `db:"src_path"`
|
||||
DstPath string `db:"dst_path"`
|
||||
@@ -182,11 +181,11 @@ func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error {
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
const q = `
|
||||
INSERT INTO file_link (download_id, apply_batch_id, src_path, dst_path, kind, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
for _, l := range links {
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status); err != nil {
|
||||
ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status); err != nil {
|
||||
return fmt.Errorf("insert file_link: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -202,7 +201,7 @@ VALUES (?, ?, ?, ?, ?, ?)`
|
||||
// раскладка забирает владение освободившимся путём, прежние записи перестают
|
||||
// считаться целью при сверке. Затрагивает только активные статусы раскладки
|
||||
// (linked/copied/exists) и не трогает саму загрузку (download_id != ?).
|
||||
func (s *Store) SupersedeForeignLinks(ctx context.Context, downloadID int64, dstPaths []string) error {
|
||||
func (s *Store) SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error {
|
||||
if len(dstPaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -226,7 +225,7 @@ WHERE download_id != ?
|
||||
|
||||
// LatestBatchID возвращает apply_batch_id последнего применённого батча
|
||||
// загрузки (для undo) либо пустую строку, если ссылок нет.
|
||||
func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (string, error) {
|
||||
func (s *Store) LatestBatchID(ctx context.Context, downloadID string) (string, error) {
|
||||
var batch string
|
||||
err := s.DB.GetContext(ctx, &batch,
|
||||
`SELECT apply_batch_id FROM file_link WHERE download_id = ?
|
||||
@@ -265,8 +264,8 @@ func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) erro
|
||||
// хранят значения для тега Jellyfin (напр. TVMaze отдаёт внешний TVDB-id —
|
||||
// см. recognize), а не обязательно нативный id провайдера поиска.
|
||||
type MetadataCandidate struct {
|
||||
ID int64 `db:"id"`
|
||||
RecognitionID int64 `db:"recognition_id"`
|
||||
ID string `db:"id"`
|
||||
RecognitionID string `db:"recognition_id"`
|
||||
Provider string `db:"provider"`
|
||||
ProviderID string `db:"provider_id"`
|
||||
Title sql.NullString `db:"title"`
|
||||
@@ -288,11 +287,11 @@ func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate)
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
const q = `
|
||||
INSERT INTO metadata_candidate (recognition_id, provider, provider_id, title, year, url)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
for _, c := range cands {
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL); err != nil {
|
||||
ident.NewID(), c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL); err != nil {
|
||||
return fmt.Errorf("insert candidate: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -303,7 +302,7 @@ VALUES (?, ?, ?, ?, ?, ?)`
|
||||
}
|
||||
|
||||
// ListCandidatesByRecognition возвращает кандидатов попытки распознавания.
|
||||
func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]MetadataCandidate, error) {
|
||||
func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID string) ([]MetadataCandidate, error) {
|
||||
var out []MetadataCandidate
|
||||
if err := s.DB.SelectContext(ctx, &out,
|
||||
`SELECT * FROM metadata_candidate WHERE recognition_id = ? ORDER BY id`, recognitionID); err != nil {
|
||||
@@ -313,21 +312,21 @@ func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID i
|
||||
}
|
||||
|
||||
// GetCandidate возвращает кандидата по id либо (nil, nil).
|
||||
func (s *Store) GetCandidate(ctx context.Context, id int64) (*MetadataCandidate, error) {
|
||||
func (s *Store) GetCandidate(ctx context.Context, id string) (*MetadataCandidate, error) {
|
||||
var c MetadataCandidate
|
||||
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get candidate %d: %w", id, err)
|
||||
return nil, fmt.Errorf("get candidate %s: %w", id, err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// SetCandidateChosen помечает кандидата выбранным, снимая отметку с прочих в
|
||||
// той же попытке распознавания.
|
||||
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error {
|
||||
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID string) error {
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
|
||||
@@ -6,14 +6,9 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func seedDownload(t *testing.T, st *Store) int64 {
|
||||
func seedDownload(t *testing.T, st *Store) string {
|
||||
t.Helper()
|
||||
id, err := st.CreateDownload(context.Background(),
|
||||
newDownloading("aabbccddeeff00112233445566778899aabbccdd"))
|
||||
if err != nil {
|
||||
t.Fatalf("seed download: %v", err)
|
||||
}
|
||||
return id
|
||||
return mustCreate(t, st, "aabbccddeeff00112233445566778899aabbccdd")
|
||||
}
|
||||
|
||||
func TestCreateRecognition_AttemptsAndCurrent(t *testing.T) {
|
||||
@@ -49,7 +44,7 @@ func TestCreateRecognition_AttemptsAndCurrent(t *testing.T) {
|
||||
t.Fatalf("get current: %v", err)
|
||||
}
|
||||
if cur.ID != id2 {
|
||||
t.Errorf("current id = %d, want %d", cur.ID, id2)
|
||||
t.Errorf("current id = %s, want %s", cur.ID, id2)
|
||||
}
|
||||
if cur.AttemptNo != 2 {
|
||||
t.Errorf("attempt_no = %d, want 2", cur.AttemptNo)
|
||||
@@ -163,11 +158,7 @@ func TestSupersedeForeignLinks(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
owner := seedDownload(t, st)
|
||||
foreign, err := st.CreateDownload(ctx,
|
||||
newDownloading("bbccddeeff00112233445566778899aabbccddee"))
|
||||
if err != nil {
|
||||
t.Fatalf("seed foreign: %v", err)
|
||||
}
|
||||
foreign := mustCreate(t, st, "bbccddeeff00112233445566778899aabbccddee")
|
||||
|
||||
shared := "/m/Movie (2024).mkv"
|
||||
// foreign разложена по shared (linked) и по своему пути (exists);
|
||||
@@ -244,7 +235,7 @@ func TestCandidates_Lifecycle(t *testing.T) {
|
||||
for _, c := range got {
|
||||
want := c.ID == chosenID
|
||||
if c.Chosen != want {
|
||||
t.Errorf("candidate %d chosen = %v, want %v", c.ID, c.Chosen, want)
|
||||
t.Errorf("candidate %s chosen = %v, want %v", c.ID, c.Chosen, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,33 +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) {
|
||||
st := newTestStore(t)
|
||||
c, err := st.GetCandidate(context.Background(), 999)
|
||||
c, err := st.GetCandidate(context.Background(), "01hzzzzzzzzzzzzzzzzzzzzzzz")
|
||||
if err != nil || c != nil {
|
||||
t.Errorf("want nil,nil; got %+v, %v", c, err)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/pressly/goose/v3"
|
||||
_ "modernc.org/sqlite" // драйвер database/sql, имя "sqlite"
|
||||
|
||||
// Go-миграции goose (регистрируются в init(); SQL-миграции — embed ниже).
|
||||
_ "git.vakhrushev.me/av/jellybit/internal/store/migrations"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
@@ -29,8 +32,12 @@ func Open(dbPath string) (*Store, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// _txlock=immediate: каждая явная транзакция открывается как BEGIN
|
||||
// IMMEDIATE — write-транзакция с самого начала. На этом держатся
|
||||
// guarded-методы инварианта «одна активная загрузка на infohash»
|
||||
// (check-then-write без гонок: SQLite сериализует писателей).
|
||||
dsn := fmt.Sprintf(
|
||||
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
|
||||
"file:%s?_txlock=immediate&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
|
||||
dbPath,
|
||||
)
|
||||
db, err := sqlx.Connect("sqlite", dsn)
|
||||
|
||||
+31
-28
@@ -4,12 +4,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
@@ -30,13 +30,13 @@ type Ingestor interface {
|
||||
|
||||
// Reviewer — операции ревью (worker.Worker).
|
||||
type Reviewer interface {
|
||||
ReviewData(ctx context.Context, id int64) (*worker.ReviewData, error)
|
||||
Apply(ctx context.Context, id int64) error
|
||||
Refine(ctx context.Context, id int64, hint string) error
|
||||
SetType(ctx context.Context, id int64, mediaType string) error
|
||||
Defer(ctx context.Context, id int64) error
|
||||
Cancel(ctx context.Context, id int64) error
|
||||
Retry(ctx context.Context, id int64) error
|
||||
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
|
||||
Apply(ctx context.Context, id string) error
|
||||
Refine(ctx context.Context, id string, hint string) error
|
||||
SetType(ctx context.Context, id string, mediaType string) error
|
||||
Defer(ctx context.Context, id string) error
|
||||
Cancel(ctx context.Context, id string) error
|
||||
Retry(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// Config — параметры бота.
|
||||
@@ -54,8 +54,8 @@ type Bot struct {
|
||||
webBase string
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex // защищает pending
|
||||
pending map[int64]int64 // chatID → downloadID, ждущий подсказку
|
||||
mu sync.Mutex // защищает pending
|
||||
pending map[int64]string // chatID → downloadID, ждущий подсказку
|
||||
}
|
||||
|
||||
// New собирает бота поверх клиента Telegram.
|
||||
@@ -71,7 +71,7 @@ func New(client teleAPI, ing Ingestor, rev Reviewer, cfg Config, log *slog.Logge
|
||||
allowed: allowed,
|
||||
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
|
||||
log: log,
|
||||
pending: map[int64]int64{},
|
||||
pending: map[int64]string{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
||||
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
|
||||
return
|
||||
}
|
||||
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+strconv.FormatInt(id, 10)+"…", nil)
|
||||
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+id+"…", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -151,9 +151,9 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
||||
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("Принято #%d — %s.", res.DownloadID, res.State)
|
||||
msg := fmt.Sprintf("Принято #%s — %s.", res.DownloadID, res.State)
|
||||
if res.Deduplicated {
|
||||
msg = fmt.Sprintf("Уже в работе #%d — %s.", res.DownloadID, res.State)
|
||||
msg = fmt.Sprintf("Уже в работе #%s — %s.", res.DownloadID, res.State)
|
||||
}
|
||||
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil)
|
||||
}
|
||||
@@ -173,8 +173,10 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
}
|
||||
|
||||
action, id, val := parseCallback(cq.Data)
|
||||
if id == 0 {
|
||||
b.answer(cq.ID, "")
|
||||
if id == "" {
|
||||
// Пустой/невалидный id — в т.ч. старые числовые кнопки, оставшиеся в
|
||||
// истории чата до перехода на ULID: отвечаем понятно, а не молчим.
|
||||
b.answer(cq.ID, "Кнопка устарела — откройте задачу в вебе")
|
||||
return
|
||||
}
|
||||
chatID := cq.Message.Chat.ID
|
||||
@@ -201,7 +203,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
case "refine":
|
||||
b.setPending(chatID, id)
|
||||
b.answer(cq.ID, "Жду подсказку")
|
||||
b.send(chatID, "Ответьте сообщением с подсказкой для #"+strconv.FormatInt(id, 10)+".", nil)
|
||||
b.send(chatID, "Ответьте сообщением с подсказкой для #"+id+".", nil)
|
||||
return
|
||||
default:
|
||||
b.answer(cq.ID, "")
|
||||
@@ -218,7 +220,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
}
|
||||
|
||||
// refreshCard перечитывает задачу и обновляет карточку на месте.
|
||||
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64) {
|
||||
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id string) {
|
||||
rd, err := b.reviewer.ReviewData(ctx, id)
|
||||
if err != nil {
|
||||
b.log.Warn("telegram refresh card failed", "download_id", id, "error", err)
|
||||
@@ -239,7 +241,7 @@ func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64
|
||||
// --- Notifier (worker.Notifier) ---
|
||||
|
||||
// Notify шлёт карточку подтверждения/готовности всем доверенным пользователям.
|
||||
func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyEvent) {
|
||||
func (b *Bot) Notify(ctx context.Context, downloadID string, event worker.NotifyEvent) {
|
||||
rd, err := b.reviewer.ReviewData(ctx, downloadID)
|
||||
if err != nil {
|
||||
b.log.Warn("telegram notify review data", "download_id", downloadID, "error", err)
|
||||
@@ -281,13 +283,13 @@ func (b *Bot) answer(callbackID, text string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) setPending(chatID, id int64) {
|
||||
func (b *Bot) setPending(chatID int64, id string) {
|
||||
b.mu.Lock()
|
||||
b.pending[chatID] = id
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bot) takePending(chatID int64) (int64, bool) {
|
||||
func (b *Bot) takePending(chatID int64) (string, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
id, ok := b.pending[chatID]
|
||||
@@ -300,20 +302,21 @@ func (b *Bot) takePending(chatID int64) (int64, bool) {
|
||||
// opErr — сообщение публичного канала Telegram по доменной ошибке: нейтральный
|
||||
// текст + download_id для корреляции с логами (полная ошибка уже там, на
|
||||
// доменной границе). Сырой err.Error() пользователю не показываем. Если id
|
||||
// операции ещё нет (downloadID == 0) — дружелюбный текст без ключа.
|
||||
func opErr(msg string, downloadID int64) string {
|
||||
if downloadID > 0 {
|
||||
return fmt.Sprintf("%s (download_id=%d).", msg, downloadID)
|
||||
// операции ещё нет (downloadID == "") — дружелюбный текст без ключа.
|
||||
func opErr(msg string, downloadID string) string {
|
||||
if downloadID != "" {
|
||||
return fmt.Sprintf("%s (download_id=%s).", msg, downloadID)
|
||||
}
|
||||
return msg + "."
|
||||
}
|
||||
|
||||
// parseCallback разбирает "action[:id[:value]]".
|
||||
func parseCallback(data string) (action string, id int64, value string) {
|
||||
// parseCallback разбирает "action[:id[:value]]". id валидируется как ULID
|
||||
// (входная граница); невалидный/устаревший (числовой) → пустая строка.
|
||||
func parseCallback(data string) (action string, id string, value string) {
|
||||
parts := strings.Split(data, ":")
|
||||
action = parts[0]
|
||||
if len(parts) > 1 {
|
||||
id, _ = strconv.ParseInt(parts[1], 10, 64)
|
||||
id, _ = ident.Parse(parts[1])
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
value = parts[2]
|
||||
|
||||
+59
-40
@@ -60,52 +60,55 @@ func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Res
|
||||
|
||||
type fakeReviewer struct {
|
||||
data *worker.ReviewData
|
||||
applied []int64
|
||||
refined map[int64]string
|
||||
typed map[int64]string
|
||||
deferred []int64
|
||||
canceled []int64
|
||||
retried []int64
|
||||
applied []string
|
||||
refined map[string]string
|
||||
typed map[string]string
|
||||
deferred []string
|
||||
canceled []string
|
||||
retried []string
|
||||
}
|
||||
|
||||
func (f *fakeReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) {
|
||||
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
|
||||
return f.data, nil
|
||||
}
|
||||
func (f *fakeReviewer) Apply(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Apply(_ context.Context, id string) error {
|
||||
f.applied = append(f.applied, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Refine(_ context.Context, id int64, hint string) error {
|
||||
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
|
||||
if f.refined == nil {
|
||||
f.refined = map[int64]string{}
|
||||
f.refined = map[string]string{}
|
||||
}
|
||||
f.refined[id] = hint
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) SetType(_ context.Context, id int64, t string) error {
|
||||
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
|
||||
if f.typed == nil {
|
||||
f.typed = map[int64]string{}
|
||||
f.typed = map[string]string{}
|
||||
}
|
||||
f.typed[id] = t
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Defer(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Defer(_ context.Context, id string) error {
|
||||
f.deferred = append(f.deferred, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Cancel(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Cancel(_ context.Context, id string) error {
|
||||
f.canceled = append(f.canceled, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Retry(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Retry(_ context.Context, id string) error {
|
||||
f.retried = append(f.retried, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
|
||||
const tid = "01arz3ndektsv4rrffq69g5fav"
|
||||
|
||||
func reviewData(state store.State) *worker.ReviewData {
|
||||
s, e := 2, 1
|
||||
return &worker.ReviewData{
|
||||
Download: store.Download{ID: 5, State: state, Context: "Фарго, второй сезон", SourceRef: "magnet:?x"},
|
||||
Download: store.Download{ID: tid, State: state, Context: "Фарго, второй сезон", SourceRef: "magnet:?x"},
|
||||
Recognition: &store.Recognition{
|
||||
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
|
||||
Reasons: `["неполный пак"]`,
|
||||
@@ -123,7 +126,7 @@ func reviewData(state store.State) *worker.ReviewData {
|
||||
func newTestBot(t *testing.T, allowed []int64) (*Bot, *fakeAPI, *fakeIngestor, *fakeReviewer) {
|
||||
t.Helper()
|
||||
api := &fakeAPI{}
|
||||
ing := &fakeIngestor{res: ingest.Result{DownloadID: 5, State: store.StateDownloading}}
|
||||
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateDownloading}}
|
||||
rev := &fakeReviewer{data: reviewData(store.StateReview)}
|
||||
b := New(api, ing, rev, Config{AllowedUserIDs: allowed, WebBaseURL: "http://host:8080"},
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
@@ -146,7 +149,7 @@ func TestBot_IngestFromMagnet(t *testing.T) {
|
||||
if ing.lastReq.Context != "крутой сериал" {
|
||||
t.Errorf("context = %q", ing.lastReq.Context)
|
||||
}
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #5") {
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+tid) {
|
||||
t.Errorf("sent = %+v", api.sent)
|
||||
}
|
||||
}
|
||||
@@ -174,10 +177,10 @@ func TestBot_NoMagnet(t *testing.T) {
|
||||
func TestBot_RefineViaReply(t *testing.T) {
|
||||
b, _, _, rev := newTestBot(t, []int64{7})
|
||||
// Кнопка «Уточнить» поставила ожидание подсказки для чата 7.
|
||||
b.setPending(7, 5)
|
||||
b.setPending(7, tid)
|
||||
b.handleMessage(context.Background(), msgFrom(7, "это второй сезон"))
|
||||
|
||||
if rev.refined[5] != "это второй сезон" {
|
||||
if rev.refined[tid] != "это второй сезон" {
|
||||
t.Errorf("refine = %v", rev.refined)
|
||||
}
|
||||
}
|
||||
@@ -191,9 +194,9 @@ func cbFrom(userID int64, data string) *tgbotapi.CallbackQuery {
|
||||
|
||||
func TestBot_CallbackApply(t *testing.T) {
|
||||
b, api, _, rev := newTestBot(t, []int64{7})
|
||||
b.handleCallback(context.Background(), cbFrom(7, "apply:5"))
|
||||
b.handleCallback(context.Background(), cbFrom(7, "apply:"+tid))
|
||||
|
||||
if len(rev.applied) != 1 || rev.applied[0] != 5 {
|
||||
if len(rev.applied) != 1 || rev.applied[0] != tid {
|
||||
t.Errorf("applied = %v", rev.applied)
|
||||
}
|
||||
if len(api.answers) != 1 {
|
||||
@@ -206,18 +209,18 @@ func TestBot_CallbackApply(t *testing.T) {
|
||||
|
||||
func TestBot_CallbackType(t *testing.T) {
|
||||
b, _, _, rev := newTestBot(t, []int64{7})
|
||||
b.handleCallback(context.Background(), cbFrom(7, "type:5:movie"))
|
||||
if rev.typed[5] != "movie" {
|
||||
b.handleCallback(context.Background(), cbFrom(7, "type:"+tid+":movie"))
|
||||
if rev.typed[tid] != "movie" {
|
||||
t.Errorf("typed = %v", rev.typed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBot_CallbackRefineSetsPending(t *testing.T) {
|
||||
b, api, _, _ := newTestBot(t, []int64{7})
|
||||
b.handleCallback(context.Background(), cbFrom(7, "refine:5"))
|
||||
b.handleCallback(context.Background(), cbFrom(7, "refine:"+tid))
|
||||
|
||||
if id, ok := b.takePending(7); !ok || id != 5 {
|
||||
t.Errorf("pending = %d,%v", id, ok)
|
||||
if id, ok := b.takePending(7); !ok || id != tid {
|
||||
t.Errorf("pending = %s,%v", id, ok)
|
||||
}
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "подсказкой") {
|
||||
t.Errorf("sent = %+v", api.sent)
|
||||
@@ -226,7 +229,7 @@ func TestBot_CallbackRefineSetsPending(t *testing.T) {
|
||||
|
||||
func TestBot_CallbackDeniesUnknown(t *testing.T) {
|
||||
b, _, _, rev := newTestBot(t, []int64{7})
|
||||
b.handleCallback(context.Background(), cbFrom(999, "apply:5"))
|
||||
b.handleCallback(context.Background(), cbFrom(999, "apply:"+tid))
|
||||
if len(rev.applied) != 0 {
|
||||
t.Error("чужой колбэк не должен исполняться")
|
||||
}
|
||||
@@ -234,12 +237,12 @@ func TestBot_CallbackDeniesUnknown(t *testing.T) {
|
||||
|
||||
func TestBot_NotifyReview(t *testing.T) {
|
||||
b, api, _, _ := newTestBot(t, []int64{7, 8})
|
||||
b.Notify(context.Background(), 5, worker.EventReview)
|
||||
b.Notify(context.Background(), tid, worker.EventReview)
|
||||
|
||||
if len(api.sent) != 2 { // обоим доверенным
|
||||
t.Fatalf("sent to %d chats, want 2", len(api.sent))
|
||||
}
|
||||
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #5") {
|
||||
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #"+tid) {
|
||||
t.Errorf("card text = %q", api.sent[0].text)
|
||||
}
|
||||
if !api.sent[0].hasKB {
|
||||
@@ -250,7 +253,7 @@ func TestBot_NotifyReview(t *testing.T) {
|
||||
func TestBot_NotifyDone(t *testing.T) {
|
||||
b, api, _, rev := newTestBot(t, []int64{7})
|
||||
rev.data = reviewData(store.StateDone)
|
||||
b.Notify(context.Background(), 5, worker.EventDone)
|
||||
b.Notify(context.Background(), tid, worker.EventDone)
|
||||
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Готово") {
|
||||
t.Errorf("sent = %+v", api.sent)
|
||||
@@ -260,7 +263,7 @@ func TestBot_NotifyDone(t *testing.T) {
|
||||
func TestBot_NotifyFailed(t *testing.T) {
|
||||
b, api, _, rev := newTestBot(t, []int64{7})
|
||||
rev.data = reviewData(store.StateFailed)
|
||||
b.Notify(context.Background(), 5, worker.EventFailed)
|
||||
b.Notify(context.Background(), tid, worker.EventFailed)
|
||||
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") {
|
||||
t.Errorf("sent = %+v", api.sent)
|
||||
@@ -273,20 +276,36 @@ func TestBot_NotifyFailed(t *testing.T) {
|
||||
func TestBot_CallbackRetry(t *testing.T) {
|
||||
b, _, _, rev := newTestBot(t, []int64{7})
|
||||
rev.data = reviewData(store.StateFailed)
|
||||
b.handleCallback(context.Background(), cbFrom(7, "retry:5"))
|
||||
b.handleCallback(context.Background(), cbFrom(7, "retry:"+tid))
|
||||
|
||||
if len(rev.retried) != 1 || rev.retried[0] != 5 {
|
||||
if len(rev.retried) != 1 || rev.retried[0] != tid {
|
||||
t.Errorf("retried = %v", rev.retried)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallback(t *testing.T) {
|
||||
a, id, v := parseCallback("type:5:series")
|
||||
if a != "type" || id != 5 || v != "series" {
|
||||
t.Errorf("got %q %d %q", a, id, v)
|
||||
a, id, v := parseCallback("type:" + tid + ":series")
|
||||
if a != "type" || id != tid || v != "series" {
|
||||
t.Errorf("got %q %q %q", a, id, v)
|
||||
}
|
||||
a, id, v = parseCallback("apply:9")
|
||||
if a != "apply" || id != 9 || v != "" {
|
||||
t.Errorf("got %q %d %q", a, id, v)
|
||||
a, id, v = parseCallback("apply:" + tid)
|
||||
if a != "apply" || id != tid || v != "" {
|
||||
t.Errorf("got %q %q %q", a, id, v)
|
||||
}
|
||||
// Устаревшая числовая кнопка (до перехода на ULID) → id пуст.
|
||||
if _, id, _ := parseCallback("apply:5"); id != "" {
|
||||
t.Errorf("legacy numeric id must be rejected, got %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
// Нажатие устаревшей кнопки со старым числовым id получает понятный ответ.
|
||||
func TestBot_CallbackStaleButton(t *testing.T) {
|
||||
b, api, _, rev := newTestBot(t, []int64{7})
|
||||
b.handleCallback(context.Background(), cbFrom(7, "apply:5"))
|
||||
if len(rev.applied) != 0 {
|
||||
t.Error("устаревшая кнопка не должна исполняться")
|
||||
}
|
||||
if len(api.answers) != 1 || !strings.Contains(api.answers[0], "устарела") {
|
||||
t.Errorf("answers = %v, want понятный ответ", api.answers)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-16
@@ -3,7 +3,6 @@ package tgbot
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
@@ -21,13 +20,13 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
||||
case store.StateReview, store.StateDeferred:
|
||||
return b.reviewCard(rd)
|
||||
case store.StateRecognizing:
|
||||
return "⏳ Распознаю #" + itoa(id) + "…", b.webOnly(id)
|
||||
return "⏳ Распознаю #" + id + "…", b.webOnly(id)
|
||||
case store.StateLinking:
|
||||
return "⏳ Раскладываю #" + itoa(id) + "…", nil
|
||||
return "⏳ Раскладываю #" + id + "…", nil
|
||||
case store.StateDone:
|
||||
return b.renderDone(rd), b.webOnly(id)
|
||||
default:
|
||||
text := fmt.Sprintf("Задача #%d — %s.", id, state)
|
||||
text := fmt.Sprintf("Задача #%s — %s.", id, state)
|
||||
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
||||
text += "\n" + msg
|
||||
}
|
||||
@@ -43,7 +42,7 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
||||
id := rd.Download.ID
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%d\n", id)
|
||||
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%s\n", id)
|
||||
if src := contextOrSource(rd); src != "" {
|
||||
fmt.Fprintf(&sb, "Источник: %s\n", shorten(src, 80))
|
||||
}
|
||||
@@ -63,7 +62,7 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
||||
|
||||
func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
|
||||
id := rd.Download.ID
|
||||
sid := itoa(id)
|
||||
sid := id
|
||||
|
||||
var row1 []tgbotapi.InlineKeyboardButton
|
||||
if len(rd.Preview) > 0 {
|
||||
@@ -90,7 +89,7 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
|
||||
func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
||||
title := rd.Plan.Title
|
||||
if title == "" {
|
||||
title = "#" + itoa(rd.Download.ID)
|
||||
title = "#" + rd.Download.ID
|
||||
}
|
||||
n := len(rd.Preview)
|
||||
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 {
|
||||
title := rd.Plan.Title
|
||||
if title == "" {
|
||||
title = "#" + itoa(rd.Download.ID)
|
||||
title = "#" + rd.Download.ID
|
||||
}
|
||||
switch event {
|
||||
case worker.EventTargetMissing:
|
||||
@@ -123,7 +122,7 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
|
||||
if rd.Download.State == store.StateStuck {
|
||||
verb = "зависла"
|
||||
}
|
||||
fmt.Fprintf(&sb, "❌ Задача #%d %s", id, verb)
|
||||
fmt.Fprintf(&sb, "❌ Задача #%s %s", id, verb)
|
||||
if code := rd.Download.ErrorCode.String; code != "" {
|
||||
fmt.Fprintf(&sb, " (%s)", code)
|
||||
}
|
||||
@@ -139,9 +138,9 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
|
||||
}
|
||||
|
||||
// retryKeyboard — клавиатура для failed/stuck: повтор + опц. ссылка в веб.
|
||||
func (b *Bot) retryKeyboard(id int64) *tgbotapi.InlineKeyboardMarkup {
|
||||
func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
row := []tgbotapi.InlineKeyboardButton{
|
||||
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+itoa(id)),
|
||||
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+id),
|
||||
}
|
||||
if url := b.reviewURL(id); url != "" {
|
||||
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
|
||||
@@ -150,7 +149,7 @@ func (b *Bot) retryKeyboard(id int64) *tgbotapi.InlineKeyboardMarkup {
|
||||
return &kb
|
||||
}
|
||||
|
||||
func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
|
||||
func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
url := b.reviewURL(id)
|
||||
if url == "" {
|
||||
return nil
|
||||
@@ -161,11 +160,11 @@ func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
|
||||
return &kb
|
||||
}
|
||||
|
||||
func (b *Bot) reviewURL(id int64) string {
|
||||
func (b *Bot) reviewURL(id string) string {
|
||||
if b.webBase == "" {
|
||||
return ""
|
||||
}
|
||||
return b.webBase + "/review/" + itoa(id)
|
||||
return b.webBase + "/review/" + id
|
||||
}
|
||||
|
||||
// --- мелкие хелперы ---
|
||||
@@ -233,5 +232,3 @@ func shorten(s string, n int) string {
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|
||||
|
||||
+49
-31
@@ -2,6 +2,7 @@ package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -10,13 +11,13 @@ import (
|
||||
)
|
||||
|
||||
// discover усыновляет новые раздачи: для каждого торрента с нашей категорией
|
||||
// ИЛИ тегом, чьего infohash ещё нет в БД, заводит задачу downloading. Дальше
|
||||
// ИЛИ тегом, чьих хешей ещё нет в БД, заводит задачу downloading. Дальше
|
||||
// её ведёт обычный reconcile. Вызывается под w.mu.
|
||||
//
|
||||
// Корректность при гонке с Ingest (другая горутина): Ingest пишет строку в
|
||||
// БД до добавления в qBit и ставит idempotency_key=infohash, на который есть
|
||||
// UNIQUE-индекс. Поэтому даже если тик и Ingest столкнутся в окне «проверил →
|
||||
// вставляю», второй INSERT упадёт на индексе, и adopt просто пропустит.
|
||||
// Корректность при гонке с Ingest (другая горутина): и adopt, и приём идут
|
||||
// через store.CreateDownloadIfNoActive — атомарный check-then-insert в одной
|
||||
// write-транзакции; при столкновении второй участник получает существующую
|
||||
// активную задачу и просто пропускает.
|
||||
func (w *Worker) discover(ctx context.Context, torrents []qbt.Torrent) {
|
||||
for _, t := range torrents {
|
||||
if w.tracked(t) {
|
||||
@@ -35,13 +36,13 @@ func (w *Worker) tracked(t qbt.Torrent) bool {
|
||||
|
||||
// adopt заводит задачу под торрент, если его ещё не видели.
|
||||
func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
||||
infohash := firstInfohash(t)
|
||||
if infohash == "" {
|
||||
hashes := torrentHashes(t)
|
||||
if len(hashes) == 0 {
|
||||
return // нечем идентифицировать (напр. ещё metaDL без хэша)
|
||||
}
|
||||
exists, err := w.store.ExistsByInfohash(ctx, infohash)
|
||||
exists, err := w.store.ExistsByInfohash(ctx, hashes...)
|
||||
if err != nil {
|
||||
w.log.Warn("discover exists check failed", "capability", capIngest, "infohash", infohash, "error", err)
|
||||
w.log.Warn("discover exists check failed", "capability", capIngest, "infohash", hashes[0], "error", err)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
@@ -49,33 +50,29 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
||||
}
|
||||
|
||||
d := &store.Download{
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
||||
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
||||
Infohash: store.NullString(infohash),
|
||||
IdempotencyKey: store.NullString(infohash),
|
||||
State: store.StateDownloading,
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: magnetURN(hashes[0]),
|
||||
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
||||
State: store.StateDownloading,
|
||||
}
|
||||
id, err := w.store.CreateDownload(ctx, d)
|
||||
existing, err := w.store.CreateDownloadIfNoActive(ctx, d, hashes)
|
||||
if err != nil {
|
||||
// Гонка: Ingest/другой тик мог вставить запись между проверкой и
|
||||
// вставкой — UNIQUE-индекс это отсёк. Если запись появилась, всё ок.
|
||||
if ex, _ := w.store.ExistsByInfohash(ctx, infohash); ex {
|
||||
return
|
||||
}
|
||||
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", infohash, "error", err)
|
||||
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", hashes[0], "error", err)
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
return // гонка с Ingest/другим тиком: задача уже заведена — всё ок
|
||||
}
|
||||
// Базис сортировки — время добавления в источник; у усыновлённого оно уже
|
||||
// известно (created_at задачи было бы моментом усыновления, не добавления).
|
||||
if t.AddedOn > 0 {
|
||||
if err := w.store.SetSourceAddedAt(ctx, id, time.Unix(t.AddedOn, 0)); err != nil {
|
||||
if err := w.store.SetSourceAddedAt(ctx, d.ID, time.Unix(t.AddedOn, 0)); err != nil {
|
||||
w.log.Warn("adopt set source_added_at failed",
|
||||
"capability", capIngest, "download_id", id, "error", err)
|
||||
"capability", capIngest, "download_id", d.ID, "error", err)
|
||||
}
|
||||
}
|
||||
w.log.Info("discover adopted torrent",
|
||||
"capability", capIngest, "download_id", id, "infohash", infohash, "name", t.Name,
|
||||
"capability", capIngest, "download_id", d.ID, "infohash", hashes[0], "name", t.Name,
|
||||
"category", t.Category, "tags", t.Tags)
|
||||
}
|
||||
|
||||
@@ -92,12 +89,33 @@ func hasTag(tags, tag string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// firstInfohash возвращает первый непустой infohash торрента (нижний регистр).
|
||||
func firstInfohash(t qbt.Torrent) string {
|
||||
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
||||
if h != "" {
|
||||
return strings.ToLower(h)
|
||||
// torrentHashes — все непустые хеши торрента (нижний регистр, без дублей,
|
||||
// v1-приоритетный порядок: v1 раньше v2). Единственный сборщик хешей
|
||||
// торрента для записи в БД: t.Hash берётся только когда qBittorrent не
|
||||
// отдал infohash_v1/v2 (старые версии API); у v2-only раздачи t.Hash — это
|
||||
// УСЕЧЁННЫЙ до 40 hex v2-хеш, хранить его нельзя (по длине он неотличим от
|
||||
// v1 и порождает битые btih-magnet при retry).
|
||||
func torrentHashes(t qbt.Torrent) []string {
|
||||
cands := []string{t.InfohashV1, t.InfohashV2}
|
||||
if t.InfohashV1 == "" && t.InfohashV2 == "" {
|
||||
cands = append(cands, t.Hash)
|
||||
}
|
||||
var out []string
|
||||
for _, h := range cands {
|
||||
h = store.NormalizeHash(h)
|
||||
if h != "" && !slices.Contains(out, h) {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return out
|
||||
}
|
||||
|
||||
// magnetURN — синтетический источник усыновлённой раздачи по её хешу:
|
||||
// btih для v1, btmh (multihash sha256, префикс 1220) для v2. Хеш обязан
|
||||
// быть полноразмерным (torrentHashes усечённые не отдаёт).
|
||||
func magnetURN(h string) string {
|
||||
if store.HashKind(h) == store.HashV2 {
|
||||
return "magnet:?xt=urn:btmh:1220" + h
|
||||
}
|
||||
return "magnet:?xt=urn:btih:" + h
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ import (
|
||||
const ihDisc = "7931aa3ed6666746012f5739d099b5bc64d72a16"
|
||||
|
||||
func emptyStore() *fakeStore {
|
||||
return &fakeStore{downloads: map[int64]*store.Download{}}
|
||||
return &fakeStore{downloads: map[string]*store.Download{}}
|
||||
}
|
||||
|
||||
// findByInfohash возвращает усыновлённую задачу по infohash.
|
||||
func findByInfohash(st *fakeStore, infohash string) *store.Download {
|
||||
for _, d := range st.downloads {
|
||||
if d.Infohash.String == infohash {
|
||||
if hasAnyHash(d, []string{infohash}) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,8 @@ func TestDiscover_AdoptsByCategory(t *testing.T) {
|
||||
if d.State != store.StateDownloading || d.SourceType != store.SourceMagnet {
|
||||
t.Errorf("adopted = %+v", d)
|
||||
}
|
||||
if d.IdempotencyKey.String != ihDisc {
|
||||
t.Errorf("idempotency_key = %q", d.IdempotencyKey.String)
|
||||
if len(d.Infohashes) != 1 || d.Infohashes[0].Kind != store.HashV1 {
|
||||
t.Errorf("infohashes = %+v", d.Infohashes)
|
||||
}
|
||||
// Усыновление берёт заголовок из имени торрента qBittorrent и фиксирует
|
||||
// время добавления (added_on) как базис сортировки.
|
||||
@@ -79,8 +79,8 @@ func TestDiscover_SkipsUntracked(t *testing.T) {
|
||||
func TestDiscover_SkipsExisting(t *testing.T) {
|
||||
st := emptyStore()
|
||||
// Уже есть задача (напр. терминальная done) — не переусыновляем.
|
||||
st.downloads[1] = &store.Download{
|
||||
ID: 1, State: store.StateDone, Infohash: store.NullString(ihDisc),
|
||||
st.downloads["1"] = &store.Download{
|
||||
ID: "1", State: store.StateDone, Infohashes: hashesOf("1", ihDisc),
|
||||
}
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
w.discover(context.Background(), []qbt.Torrent{
|
||||
@@ -104,9 +104,9 @@ func TestDiscover_SkipsNoInfohash(t *testing.T) {
|
||||
// уже скачанная раздача за один тик усыновляется и доходит до completed.
|
||||
func TestPoll_CapturesSourceAddedAt(t *testing.T) {
|
||||
ih := "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
|
||||
st := &fakeStore{downloads: map[int64]*store.Download{
|
||||
1: {ID: 1, State: store.StateDownloading, SourceType: store.SourceMagnet,
|
||||
Infohash: store.NullString(ih), IdempotencyKey: store.NullString(ih)},
|
||||
st := &fakeStore{downloads: map[string]*store.Download{
|
||||
"1": {ID: "1", State: store.StateDownloading, SourceType: store.SourceMagnet,
|
||||
Infohashes: hashesOf("1", ih)},
|
||||
}}
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{
|
||||
{Hash: ih, Name: "X", Category: "jellybit", State: "downloading", AddedOn: 1_700_000_000},
|
||||
@@ -116,7 +116,7 @@ func TestPoll_CapturesSourceAddedAt(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if d := st.downloads[1]; !d.SourceAddedAt.Valid {
|
||||
if d := st.downloads["1"]; !d.SourceAddedAt.Valid {
|
||||
t.Fatalf("source_added_at не захвачен при поллинге активной задачи")
|
||||
}
|
||||
}
|
||||
@@ -160,14 +160,60 @@ func TestHasTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstInfohash(t *testing.T) {
|
||||
if got := firstInfohash(qbt.Torrent{Hash: "ABC"}); got != "abc" {
|
||||
t.Errorf("got %q", got)
|
||||
func TestTorrentHashes(t *testing.T) {
|
||||
got := torrentHashes(qbt.Torrent{Hash: "ABC", InfohashV1: "abc", InfohashV2: "DEF"})
|
||||
if len(got) != 2 || got[0] != "abc" || got[1] != "def" {
|
||||
t.Errorf("got %v, want [abc def] (lowercase, без дублей, v1 первым)", got)
|
||||
}
|
||||
if got := firstInfohash(qbt.Torrent{InfohashV2: "DEF"}); got != "def" {
|
||||
t.Errorf("got %q", got)
|
||||
if got := torrentHashes(qbt.Torrent{}); len(got) != 0 {
|
||||
t.Errorf("got %v, want empty", got)
|
||||
}
|
||||
if got := firstInfohash(qbt.Torrent{}); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
// Старый qBittorrent без infohash_v1/v2 — берём hash.
|
||||
if got := torrentHashes(qbt.Torrent{Hash: "ABC"}); len(got) != 1 || got[0] != "abc" {
|
||||
t.Errorf("legacy hash: got %v, want [abc]", got)
|
||||
}
|
||||
// v2-only: t.Hash — УСЕЧЁННЫЙ v2 (40 hex), хранить его нельзя.
|
||||
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
got = torrentHashes(qbt.Torrent{Hash: v2[:40], InfohashV2: v2})
|
||||
if len(got) != 1 || got[0] != v2 {
|
||||
t.Errorf("v2-only: got %v, want только полный v2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Усыновление v2-only раздачи: SourceRef — валидный btmh-magnet из полного
|
||||
// v2-хеша (не битый btih из усечённого), kind в БД — v2.
|
||||
func TestDiscover_AdoptsV2Only(t *testing.T) {
|
||||
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
st := emptyStore()
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
w.discover(context.Background(), []qbt.Torrent{
|
||||
{Hash: v2[:40], InfohashV2: v2, Name: "V2Only", Category: "jellybit", State: "downloading"},
|
||||
})
|
||||
d := findByInfohash(st, v2)
|
||||
if d == nil {
|
||||
t.Fatal("v2-only раздача не усыновлена")
|
||||
}
|
||||
if d.SourceRef != "magnet:?xt=urn:btmh:1220"+v2 {
|
||||
t.Errorf("SourceRef = %q, want btmh с полным v2", d.SourceRef)
|
||||
}
|
||||
if len(d.Infohashes) != 1 || d.Infohashes[0].Kind != store.HashV2 {
|
||||
t.Errorf("infohashes = %+v, want один v2 (усечённый не хранится)", d.Infohashes)
|
||||
}
|
||||
}
|
||||
|
||||
// Усыновление гибридного торрента записывает оба хеша.
|
||||
func TestDiscover_AdoptsBothHashes(t *testing.T) {
|
||||
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
st := emptyStore()
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
w.discover(context.Background(), []qbt.Torrent{
|
||||
{Hash: ihDisc, InfohashV1: ihDisc, InfohashV2: v2, Name: "Hybrid", Category: "jellybit", State: "downloading"},
|
||||
})
|
||||
d := findByInfohash(st, v2)
|
||||
if d == nil {
|
||||
t.Fatal("гибридная раздача не находится по v2-хешу")
|
||||
}
|
||||
if len(d.Infohashes) != 2 {
|
||||
t.Errorf("infohashes = %+v, want v1+v2", d.Infohashes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestPollBuildsLiveSnapshot(t *testing.T) {
|
||||
Ratio: 2.5, NumSeeds: 3, NumLeechs: 1, Uploaded: 999, Upspeed: 50,
|
||||
},
|
||||
}}
|
||||
w := newTestWorker(&fakeStore{downloads: map[int64]*store.Download{}}, qb)
|
||||
w := newTestWorker(&fakeStore{downloads: map[string]*store.Download{}}, qb)
|
||||
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
@@ -57,11 +56,11 @@ func (w *Worker) reconcileDesync(ctx context.Context, byHash map[string]qbt.Torr
|
||||
// reconcileOneDesync сверяет одну задачу: вычисляет присутствие источника (с
|
||||
// дебаунсом) и цели, выводит состояние и переходит при изменении.
|
||||
func (w *Worker) reconcileOneDesync(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
|
||||
if !d.Infohash.Valid {
|
||||
if len(d.Infohashes) == 0 {
|
||||
return // нечем сопоставить источник
|
||||
}
|
||||
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
|
||||
_, sourceSeen := byHash[strings.ToLower(d.Infohash.String)]
|
||||
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
||||
_, sourceSeen := torrentFor(d, byHash)
|
||||
|
||||
// Дебаунс пропажи источника: считаем удалённым только после порога подряд
|
||||
// идущих промахов; любое появление сбрасывает счётчик.
|
||||
@@ -104,7 +103,7 @@ func (w *Worker) debounceSource(ctx context.Context, d store.Download, sourceSee
|
||||
// targetPresent сообщает, существуют ли разложенные хардлинки задачи. Цель
|
||||
// считается присутствующей, только если существуют ВСЕ ссылки последнего
|
||||
// батча; частичная пропажа — это отсутствие цели (библиотека сломана → relink).
|
||||
func (w *Worker) targetPresent(ctx context.Context, id int64) (bool, error) {
|
||||
func (w *Worker) targetPresent(ctx context.Context, id string) (bool, error) {
|
||||
batch, err := w.store.LatestBatchID(ctx, id)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("latest batch: %w", err)
|
||||
@@ -180,10 +179,10 @@ func (w *Worker) reconcileRecovery(ctx context.Context, byHash map[string]qbt.To
|
||||
// reconcileOneRecovery возвращает одну зависшую задачу в поток, если её торрент
|
||||
// присутствует и продвинулся за условие падения.
|
||||
func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
|
||||
if !d.Infohash.Valid {
|
||||
if len(d.Infohashes) == 0 {
|
||||
return
|
||||
}
|
||||
t, ok := byHash[strings.ToLower(d.Infohash.String)]
|
||||
t, ok := torrentFor(d, byHash)
|
||||
if !ok {
|
||||
return // источника нет — оставляем как есть (вернёт ручной retry)
|
||||
}
|
||||
@@ -194,28 +193,22 @@ func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byH
|
||||
if want == "" {
|
||||
return // переходное состояние qBit (moving/checking) — ждём
|
||||
}
|
||||
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
||||
|
||||
// Конфликт идемпотентности: пока задача лежала в failed, тот же infohash мог
|
||||
// взять другая активная задача (idempotency_key снят при падении). Оба
|
||||
// целевых состояния (downloading/completed) нетерминальны → SetDownloadState
|
||||
// восстановит idempotency_key = infohash; при занятом ключе упёрлись бы в
|
||||
// unique-индекс. Поэтому проверяем владельца независимо от целевого состояния
|
||||
// и оставляем старую задачу в failed.
|
||||
other, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
|
||||
if err != nil {
|
||||
logctx.From(ctx).Warn("recovery active lookup failed", "error", err)
|
||||
return
|
||||
}
|
||||
if other != nil && other.ID != d.ID {
|
||||
logctx.From(ctx).Info("recovery skipped, infohash taken by active download",
|
||||
"conflict_download_id", other.ID)
|
||||
return
|
||||
}
|
||||
// Возврат в активное состояние — только через атомарный гард инварианта:
|
||||
// пока задача лежала в failed, тем же infohash могла завладеть другая
|
||||
// активная задача (новый приём). Тогда старую оставляем в failed.
|
||||
// error_code/error_msg не пишем — задача снова здорова; причину в лог, а не в
|
||||
// поле ошибки (иначе она светилась бы в UI/REST как ошибка живой задачи).
|
||||
logctx.From(ctx).Info("recovery from failure", "to", want, "qbit_state", t.State)
|
||||
w.transition(ctx, d, want, "", "")
|
||||
if err := w.store.ActivateIfNoOtherActive(ctx, d.ID, want, "", ""); err != nil {
|
||||
if errors.Is(err, store.ErrInfohashTaken) {
|
||||
logctx.From(ctx).Info("recovery skipped, infohash taken by active download", "error", err)
|
||||
return
|
||||
}
|
||||
logctx.From(ctx).Warn("recovery activate failed", "error", err)
|
||||
return
|
||||
}
|
||||
logctx.From(ctx).Info("recovery from failure", "from", d.State, "to", want, "qbit_state", t.State)
|
||||
}
|
||||
|
||||
// torrentProgressed сообщает, продвинулся ли торрент за условие, по которому
|
||||
@@ -256,10 +249,10 @@ func recoveredState(state string) store.State {
|
||||
// qBittorrent прямо сейчас. При отсутствии приводит состояние к реальности и
|
||||
// возвращает ErrConflict. Недоступность qBittorrent — честный отказ операции.
|
||||
func (w *Worker) ensureSourcePresent(ctx context.Context, d *store.Download, op string) error {
|
||||
if !d.Infohash.Valid {
|
||||
return fmt.Errorf("%s: download %d has no infohash", op, d.ID)
|
||||
if len(d.Infohashes) == 0 {
|
||||
return fmt.Errorf("%s: download %s has no infohash", op, d.ID)
|
||||
}
|
||||
_, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
||||
_, ok, err := w.torrentByInfohash(ctx, d.HashList())
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", op, err)
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ func newReconcileFixture(t *testing.T, state store.State, sourcePresent, makeTar
|
||||
}
|
||||
|
||||
st := newMemStore()
|
||||
d := completedDownload(1)
|
||||
d := completedDownload("1")
|
||||
d.State = state
|
||||
st.put(d)
|
||||
st.links = append(st.links, store.FileLink{
|
||||
DownloadID: 1, ApplyBatchID: "b1", SrcPath: src, DstPath: dst,
|
||||
DownloadID: "1", ApplyBatchID: "b1", SrcPath: src, DstPath: dst,
|
||||
Kind: "video", Status: "linked",
|
||||
})
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestReconcileMatrix(t *testing.T) {
|
||||
if err := f.w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != tc.want {
|
||||
if got := f.st.downloads["1"].State; got != tc.want {
|
||||
t.Errorf("state = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
@@ -78,7 +78,7 @@ func TestReconcileHealing(t *testing.T) {
|
||||
if err := f.w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateDone {
|
||||
if got := f.st.downloads["1"].State; got != store.StateDone {
|
||||
t.Errorf("state = %q, want done (healing)", got)
|
||||
}
|
||||
}
|
||||
@@ -88,13 +88,13 @@ func TestReconcilePartialTargetLoss(t *testing.T) {
|
||||
f := newReconcileFixture(t, store.StateDone, true, true)
|
||||
missing := filepath.Join(filepath.Dir(f.dst), "Movie (2024).en.srt")
|
||||
f.st.links = append(f.st.links, store.FileLink{
|
||||
DownloadID: 1, ApplyBatchID: "b1", SrcPath: "/x.srt", DstPath: missing,
|
||||
DownloadID: "1", ApplyBatchID: "b1", SrcPath: "/x.srt", DstPath: missing,
|
||||
Kind: "subtitle", Status: "linked",
|
||||
})
|
||||
if err := f.w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateTargetMissing {
|
||||
if got := f.st.downloads["1"].State; got != store.StateTargetMissing {
|
||||
t.Errorf("state = %q, want target_missing (частичная пропажа)", got)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestReconcileSkipsDeleted(t *testing.T) {
|
||||
if err := f.w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateDeleted {
|
||||
if got := f.st.downloads["1"].State; got != store.StateDeleted {
|
||||
t.Errorf("state = %q, want deleted (сверка не трогает терминальное)", got)
|
||||
}
|
||||
}
|
||||
@@ -121,17 +121,17 @@ func TestReconcileDebounce(t *testing.T) {
|
||||
if err := f.w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll %d: %v", i, err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateDone {
|
||||
if got := f.st.downloads["1"].State; got != store.StateDone {
|
||||
t.Fatalf("tick %d: state = %q, want done (до порога)", i, got)
|
||||
}
|
||||
if got := f.st.downloads[1].SourceMissCount; got != i {
|
||||
if got := f.st.downloads["1"].SourceMissCount; got != i {
|
||||
t.Errorf("tick %d: miss = %d, want %d", i, got, i)
|
||||
}
|
||||
}
|
||||
if err := f.w.Poll(context.Background()); err != nil { // третий промах
|
||||
t.Fatalf("Poll 3: %v", err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateOrphaned {
|
||||
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
|
||||
t.Fatalf("tick 3: state = %q, want orphaned (порог достигнут)", got)
|
||||
}
|
||||
|
||||
@@ -140,10 +140,10 @@ func TestReconcileDebounce(t *testing.T) {
|
||||
if err := f.w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll heal: %v", err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateDone {
|
||||
if got := f.st.downloads["1"].State; got != store.StateDone {
|
||||
t.Errorf("state = %q, want done (источник вернулся)", got)
|
||||
}
|
||||
if got := f.st.downloads[1].SourceMissCount; got != 0 {
|
||||
if got := f.st.downloads["1"].SourceMissCount; got != 0 {
|
||||
t.Errorf("miss = %d, want 0 (сброс)", got)
|
||||
}
|
||||
}
|
||||
@@ -154,18 +154,18 @@ func TestReconcileSkipsActiveStates(t *testing.T) {
|
||||
if err := f.w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateDownloading {
|
||||
if got := f.st.downloads["1"].State; got != store.StateDownloading {
|
||||
t.Errorf("state = %q, want downloading (сверка не трогает активные)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndoRejectedForOrphaned(t *testing.T) {
|
||||
f := newReconcileFixture(t, store.StateOrphaned, false, true)
|
||||
err := f.w.Undo(context.Background(), 1)
|
||||
err := f.w.Undo(context.Background(), "1")
|
||||
if err == nil {
|
||||
t.Fatal("ожидали отказ Undo для orphaned")
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateOrphaned {
|
||||
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
|
||||
t.Errorf("state = %q, want orphaned (без изменений)", got)
|
||||
}
|
||||
}
|
||||
@@ -173,14 +173,14 @@ func TestUndoRejectedForOrphaned(t *testing.T) {
|
||||
func TestRelinkFromTargetMissing(t *testing.T) {
|
||||
// target_missing + источник на месте → relink ведёт в recognizing.
|
||||
f := newReconcileFixture(t, store.StateTargetMissing, true, false)
|
||||
if err := f.w.Relink(context.Background(), 1); err != nil {
|
||||
if err := f.w.Relink(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Relink: %v", err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateRecognizing {
|
||||
if got := f.st.downloads["1"].State; got != store.StateRecognizing {
|
||||
t.Errorf("state = %q, want recognizing", got)
|
||||
}
|
||||
if f.st.overrides[1][ovrForceReview] != "1" {
|
||||
t.Errorf("force_review = %q, want 1", f.st.overrides[1][ovrForceReview])
|
||||
if f.st.overrides["1"][ovrForceReview] != "1" {
|
||||
t.Errorf("force_review = %q, want 1", f.st.overrides["1"][ovrForceReview])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,10 +189,10 @@ func TestPreflightFixesStaleState(t *testing.T) {
|
||||
// а цель на месте: relink немедленно приводит состояние к orphaned, не
|
||||
// дожидаясь фоновой сверки.
|
||||
f := newReconcileFixture(t, store.StateTargetMissing, false, true)
|
||||
if err := f.w.Relink(context.Background(), 1); err == nil {
|
||||
if err := f.w.Relink(context.Background(), "1"); err == nil {
|
||||
t.Fatal("ожидали отказ relink при пропавшем источнике")
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateOrphaned {
|
||||
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
|
||||
t.Errorf("state = %q, want orphaned (preflight привёл к реальности)", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ import (
|
||||
var addedRecent = time.Date(2026, 6, 14, 9, 59, 0, 0, time.UTC).Unix()
|
||||
|
||||
func oneFailed(state store.State, code, infohash, createdAt string) *fakeStore {
|
||||
return &fakeStore{downloads: map[int64]*store.Download{
|
||||
1: {
|
||||
ID: 1,
|
||||
return &fakeStore{downloads: map[string]*store.Download{
|
||||
"1": {
|
||||
ID: "1",
|
||||
State: state,
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
||||
Infohash: store.NullString(infohash),
|
||||
Infohashes: hashesOf("1", infohash),
|
||||
ErrorCode: store.NullString(code),
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
@@ -52,7 +52,7 @@ func TestRecovery(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := st.downloads[1].State; got != tc.want {
|
||||
if got := st.downloads["1"].State; got != tc.want {
|
||||
t.Errorf("state = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
@@ -68,8 +68,8 @@ func TestRecoveryNoSourceStaysFailed(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateFailed {
|
||||
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateFailed {
|
||||
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,11 +78,11 @@ func TestRecoveryNoSourceStaysFailed(t *testing.T) {
|
||||
func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
||||
st.downloads[2] = &store.Download{
|
||||
ID: 2,
|
||||
st.downloads["2"] = &store.Download{
|
||||
ID: "2",
|
||||
State: store.StateDownloading,
|
||||
SourceType: store.SourceMagnet,
|
||||
Infohash: store.NullString(ih),
|
||||
Infohashes: hashesOf("2", ih),
|
||||
CreatedAt: timeRecent,
|
||||
}
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}}
|
||||
@@ -90,8 +90,8 @@ func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateFailed {
|
||||
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateFailed {
|
||||
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,11 +101,11 @@ func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
|
||||
func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
||||
st.downloads[2] = &store.Download{
|
||||
ID: 2,
|
||||
st.downloads["2"] = &store.Download{
|
||||
ID: "2",
|
||||
State: store.StateDownloading,
|
||||
SourceType: store.SourceMagnet,
|
||||
Infohash: store.NullString(ih),
|
||||
Infohashes: hashesOf("2", ih),
|
||||
CreatedAt: timeRecent,
|
||||
}
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "uploading", AddedOn: addedRecent}}}
|
||||
@@ -113,8 +113,8 @@ func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateFailed {
|
||||
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateFailed {
|
||||
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ func TestFailNotifyDebounce(t *testing.T) {
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
|
||||
w.SetNotifier(n)
|
||||
d := *st.downloads[1]
|
||||
d := *st.downloads["1"]
|
||||
|
||||
w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
|
||||
if e := waitNotify(t, n); e.ev != EventFailed {
|
||||
@@ -151,11 +151,11 @@ func TestRetryReattachesNoReadd(t *testing.T) {
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}}
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
if err := w.Retry(context.Background(), 1); err != nil {
|
||||
if err := w.Retry(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Retry: %v", err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateDownloading {
|
||||
t.Fatalf("после retry ожидался downloading, got %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateDownloading {
|
||||
t.Fatalf("после retry ожидался downloading, got %q", st.downloads["1"].State)
|
||||
}
|
||||
if len(qb.added) != 0 {
|
||||
t.Errorf("живой торрент не должен добавляться повторно, got %d Add", len(qb.added))
|
||||
@@ -164,7 +164,7 @@ func TestRetryReattachesNoReadd(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateDownloading {
|
||||
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateDownloading {
|
||||
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
+64
-60
@@ -50,7 +50,7 @@ func (w *Worker) recognizePending(ctx context.Context) {
|
||||
// под блокировкой переводим в recognizing, LLM зовём без блокировки, затем
|
||||
// под блокировкой фиксируем результат — но только если задачу за это время
|
||||
// не увели в другое состояние (cancel/defer).
|
||||
func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
||||
func (w *Worker) recognizeOne(ctx context.Context, id string) {
|
||||
w.mu.Lock()
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
@@ -62,7 +62,7 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ctx = w.scoped(ctx, capRecognize, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capRecognize, id, d.PrimaryInfohash())
|
||||
if d.State == store.StateCompleted {
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
}
|
||||
@@ -84,10 +84,10 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
||||
// затем зовёт распознаватель. Возвращает также savePath для маппинга
|
||||
// относительных путей файлов в абсолютные при раскладке.
|
||||
func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.Result, string, error) {
|
||||
if !d.Infohash.Valid {
|
||||
if len(d.Infohashes) == 0 {
|
||||
return recognize.Result{}, "", fmt.Errorf("no infohash")
|
||||
}
|
||||
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
||||
t, ok, err := w.torrentByInfohash(ctx, d.HashList())
|
||||
if err != nil {
|
||||
return recognize.Result{}, "", err
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
|
||||
|
||||
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
|
||||
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review.
|
||||
func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.Result, savePath string) {
|
||||
func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize.Result, savePath string) {
|
||||
log := logctx.From(ctx)
|
||||
planJSON, err := json.Marshal(res.Plan)
|
||||
if err != nil {
|
||||
@@ -174,6 +174,9 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
|
||||
log.Error("recognition persist failed", "error", err)
|
||||
return
|
||||
}
|
||||
// recognition_id — ключ корреляции попытки (грепается и голым ULID).
|
||||
log.Info("recognition persisted", "recognition_id", recID,
|
||||
"provider", provider, "provider_id", providerID)
|
||||
// Кандидаты базы — для ручного выбора в review.
|
||||
if cands := toStoreCandidates(recID, res.Candidates); len(cands) > 0 {
|
||||
if err := w.store.CreateCandidates(ctx, cands); err != nil {
|
||||
@@ -189,7 +192,7 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
|
||||
forceReview := overrides[ovrForceReview] == "1"
|
||||
if res.Decision.Auto && !forceReview && w.layouter != nil {
|
||||
plan := applyOverrides(res.Plan, overrides)
|
||||
lctx := w.scoped(ctx, capFileLayout, id, d.Infohash.String)
|
||||
lctx := w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
||||
w.transition(lctx, *d, store.StateLinking, "", "")
|
||||
if err := w.linkPlan(lctx, d, plan, tag, savePath); err != nil {
|
||||
logctx.From(lctx).Warn("auto-apply failed, left for review", "error", err)
|
||||
@@ -200,7 +203,7 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
|
||||
}
|
||||
|
||||
// overridesOrNil читает правки, проглатывая ошибку (для авто-пути).
|
||||
func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string {
|
||||
func (w *Worker) overridesOrNil(ctx context.Context, id string) map[string]string {
|
||||
o, err := w.store.ListOverrides(ctx, id)
|
||||
if err != nil {
|
||||
logctx.From(ctx).Warn("recognition list overrides failed", "error", err)
|
||||
@@ -213,7 +216,7 @@ func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string
|
||||
|
||||
// Apply создаёт хардлинки по текущему плану (с применёнными правками) и
|
||||
// переводит задачу в done. Коллизия цели → остаёмся в review с причиной.
|
||||
func (w *Worker) Apply(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Apply(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.layouter == nil {
|
||||
@@ -225,15 +228,15 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("apply: %w", err)
|
||||
}
|
||||
if d.State != store.StateReview && d.State != store.StateDeferred {
|
||||
return fmt.Errorf("apply: download %d is in state %s (expected review/deferred): %w", id, d.State, ErrConflict)
|
||||
return fmt.Errorf("apply: download %s is in state %s (expected review/deferred): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
||||
|
||||
plan, tag, err := w.effectivePlan(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply: %w", err)
|
||||
}
|
||||
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
||||
t, ok, err := w.torrentByInfohash(ctx, d.HashList())
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply: lookup torrent: %w", err)
|
||||
}
|
||||
@@ -308,7 +311,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
}
|
||||
|
||||
w.transition(ctx, *d, store.StateDone, "", "")
|
||||
logctx.From(ctx).Info("layout linked", "batch", batch, "links", len(fl))
|
||||
logctx.From(ctx).Info("layout linked", "batch_id", batch, "links", len(fl))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -317,7 +320,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
|
||||
// перепривязка всегда проходит через ревью с подтверждением (force_review).
|
||||
// Источник (раздача в qBittorrent) для этого должен быть на месте.
|
||||
func (w *Worker) Relink(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Relink(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -326,28 +329,26 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
if d.State != store.StateReverted && d.State != store.StateCancelled && d.State != store.StateTargetMissing {
|
||||
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
|
||||
return fmt.Errorf("relink: download %s is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
// Источник нужен для распознавания — проверяем синхронно (без дебаунса) и при
|
||||
// его отсутствии приводим состояние к реальности (orphaned/deleted).
|
||||
if err := w.ensureSourcePresent(ctx, d, "relink"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Вернуть задачу в активную обработку можно, только если другой активной
|
||||
// задачи на этот infohash нет (partial unique index по idempotency_key).
|
||||
active, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
|
||||
if err != nil {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
if active != nil {
|
||||
return fmt.Errorf("relink: для этого торрента уже есть активная задача #%d", active.ID)
|
||||
}
|
||||
// Ручная перепривязка — всегда с подтверждением, без авто-раскладки.
|
||||
if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
// Возврат в активную обработку — только через атомарный гард инварианта
|
||||
// «не более одной активной задачи на infohash» (см. design ulid-identity, D4).
|
||||
if err := w.store.ActivateIfNoOtherActive(ctx, id, store.StateRecognizing, "", ""); err != nil {
|
||||
if errors.Is(err, store.ErrInfohashTaken) {
|
||||
return fmt.Errorf("relink: для этого торрента уже есть активная задача: %w", ErrConflict)
|
||||
}
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
logctx.From(ctx).Info("relink re-recognizing", "from", d.State)
|
||||
return nil
|
||||
}
|
||||
@@ -355,7 +356,7 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
|
||||
// Rerecognize перезапускает распознавание для задачи в review/deferred без
|
||||
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
|
||||
// цикл проведёт задачу recognizing → review заново.
|
||||
func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Rerecognize(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -366,14 +367,14 @@ func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
|
||||
if err := w.ensureSourcePresent(ctx, d, "rerecognize"); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
logctx.From(ctx).Info("review re-recognizing without hint")
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Refine добавляет подсказку и отправляет задачу на перераспознавание.
|
||||
func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
||||
func (w *Worker) Refine(ctx context.Context, id string, hint string) error {
|
||||
hint = strings.TrimSpace(hint)
|
||||
if hint == "" {
|
||||
return fmt.Errorf("refine: empty hint")
|
||||
@@ -388,7 +389,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
||||
if err := w.ensureSourcePresent(ctx, d, "refine"); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
if err := w.store.AddHint(ctx, id, hint); err != nil {
|
||||
return fmt.Errorf("refine: %w", err)
|
||||
}
|
||||
@@ -399,7 +400,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
||||
|
||||
// SetType фиксирует тип (override) и перезапускает распознавание с подсказкой
|
||||
// — чтобы LLM пересобрал роли файлов под новый тип.
|
||||
func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error {
|
||||
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) error {
|
||||
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
|
||||
return fmt.Errorf("set type: invalid type %q", mediaType)
|
||||
}
|
||||
@@ -413,7 +414,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
|
||||
if err := w.ensureSourcePresent(ctx, d, "set type"); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil {
|
||||
return fmt.Errorf("set type: %w", err)
|
||||
}
|
||||
@@ -430,7 +431,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
|
||||
|
||||
// IgnoreFile помечает файл к игнорированию (не линкуем). Остаёмся в review;
|
||||
// превью пересчитается с учётом правки.
|
||||
func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
|
||||
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) error {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return fmt.Errorf("ignore: empty path")
|
||||
@@ -454,12 +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 {
|
||||
return fmt.Errorf("ignore: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review file ignored", "src", src)
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review file ignored", "src", src)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Defer паркует задачу в deferred (вернётся в ревью по действию).
|
||||
func (w *Worker) Defer(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Defer(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -468,9 +469,9 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("defer: %w", err)
|
||||
}
|
||||
if d.State.IsTerminal() {
|
||||
return fmt.Errorf("defer: download %d is terminal (%s)", id, d.State)
|
||||
return fmt.Errorf("defer: download %s is terminal (%s)", id, d.State)
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
w.transition(ctx, *d, store.StateDeferred, "", "")
|
||||
return nil
|
||||
}
|
||||
@@ -479,7 +480,7 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
|
||||
// Источник недосягаем (раскладчик удаляет только пути под библиотекой). Откат
|
||||
// снимает ЛИШНИЙ хардлинк, а не последнюю копию: layout.Undo отказывается
|
||||
// удалять ссылку, если источник уже пропал (nlink<=1) — см. state-reconciliation.
|
||||
func (w *Worker) Undo(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Undo(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.layouter == nil {
|
||||
@@ -496,9 +497,9 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("undo: источник удалён, цель — последняя копия данных, откат невозможен: %w", ErrConflict)
|
||||
}
|
||||
if d.State != store.StateDone {
|
||||
return fmt.Errorf("undo: download %d is in state %s (expected done): %w", id, d.State, ErrConflict)
|
||||
return fmt.Errorf("undo: download %s is in state %s (expected done): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
||||
batch, err := w.store.LatestBatchID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("undo: %w", err)
|
||||
@@ -528,18 +529,18 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("undo: %w", err)
|
||||
}
|
||||
w.transition(ctx, *d, store.StateReverted, "", "")
|
||||
logctx.From(ctx).Info("layout reverted", "batch", batch, "removed", n)
|
||||
logctx.From(ctx).Info("layout reverted", "batch_id", batch, "removed", n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireReviewable проверяет, что задача в review/deferred. Вызывается под mu.
|
||||
func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*store.Download, error) {
|
||||
func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*store.Download, error) {
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", op, err)
|
||||
}
|
||||
if d.State != store.StateReview && d.State != store.StateDeferred {
|
||||
return nil, fmt.Errorf("%s: download %d is in state %s (expected review/deferred): %w", op, id, d.State, ErrConflict)
|
||||
return nil, fmt.Errorf("%s: download %s is in state %s (expected review/deferred): %w", op, id, d.State, ErrConflict)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -549,7 +550,7 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
|
||||
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
|
||||
// id, каноническое имя/год). Раскладку не запускает — превью обновится, а
|
||||
// человек подтвердит «Применить».
|
||||
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) error {
|
||||
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -566,7 +567,7 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) err
|
||||
return fmt.Errorf("choose candidate: %w", err)
|
||||
}
|
||||
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
|
||||
return fmt.Errorf("choose candidate: candidate %d does not belong to the current recognition", candidateID)
|
||||
return fmt.Errorf("choose candidate: candidate %s does not belong to the current recognition", candidateID)
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("choose candidate: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review candidate chosen",
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
|
||||
"provider", cand.Provider, "provider_id", cand.ProviderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProviderID пиннит провайдера и id вручную (без выбора из списка).
|
||||
func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, providerID string) error {
|
||||
func (w *Worker) SetProviderID(ctx context.Context, id string, provider, providerID string) error {
|
||||
provider = strings.TrimSpace(strings.ToLower(provider))
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
switch provider {
|
||||
@@ -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 {
|
||||
return fmt.Errorf("set provider: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider set",
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider set",
|
||||
"provider", provider, "provider_id", providerID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearProvider — «без базы»: снимает матч (тег папки не ставится).
|
||||
func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
|
||||
func (w *Worker) ClearProvider(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -654,12 +655,12 @@ type ReviewData struct {
|
||||
}
|
||||
|
||||
// ReviewData собирает данные ревью по загрузке.
|
||||
func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error) {
|
||||
func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error) {
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("review data: %w", err)
|
||||
}
|
||||
log := logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String))
|
||||
log := logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash()))
|
||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("review data: %w", err)
|
||||
@@ -709,7 +710,7 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
|
||||
|
||||
// effectivePlan загружает текущий план, применяет правки и возвращает
|
||||
// provider-тег для имени папки (под mu).
|
||||
func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, string, error) {
|
||||
func (w *Worker) effectivePlan(ctx context.Context, id string) (recognize.Plan, string, error) {
|
||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||
if err != nil {
|
||||
return recognize.Plan{}, "", err
|
||||
@@ -772,7 +773,7 @@ func effectiveProvider(rec *store.Recognition, overrides map[string]string) (pro
|
||||
|
||||
// toStoreCandidates переводит кандидатов распознавания в строки БД,
|
||||
// подставляя тег-предпочтительный provider/id (внешний из TVMaze и т.п.).
|
||||
func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store.MetadataCandidate {
|
||||
func toStoreCandidates(recognitionID string, cands []metadata.Candidate) []store.MetadataCandidate {
|
||||
out := make([]store.MetadataCandidate, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
prov, id := recognize.CandidateTag(c)
|
||||
@@ -863,19 +864,22 @@ func mapRole(r recognize.FileRole) (layout.Role, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// torrentByInfohash ищет торрент по infohash (v1/v2/hash). Листаем ВСЕ
|
||||
// торренты (а не только свою категорию): раздача могла быть усыновлена по
|
||||
// тегу и иметь чужую/пустую категорию — фильтр по категории её бы потерял
|
||||
// (как и в Poll, см. там же).
|
||||
func (w *Worker) torrentByInfohash(ctx context.Context, infohash string) (qbt.Torrent, bool, error) {
|
||||
// torrentByInfohash ищет торрент по любому из хешей загрузки (v1/v2/hash).
|
||||
// Листаем ВСЕ торренты (а не только свою категорию): раздача могла быть
|
||||
// усыновлена по тегу и иметь чужую/пустую категорию — фильтр по категории её
|
||||
// бы потерял (как и в Poll, см. там же).
|
||||
func (w *Worker) torrentByInfohash(ctx context.Context, hashes []string) (qbt.Torrent, bool, error) {
|
||||
torrents, err := w.qbt.Torrents(ctx, "")
|
||||
if err != nil {
|
||||
return qbt.Torrent{}, false, err
|
||||
}
|
||||
want := strings.ToLower(infohash)
|
||||
want := make(map[string]bool, len(hashes))
|
||||
for _, h := range hashes {
|
||||
want[store.NormalizeHash(h)] = true
|
||||
}
|
||||
for _, t := range torrents {
|
||||
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
||||
if h != "" && strings.ToLower(h) == want {
|
||||
if h != "" && want[strings.ToLower(h)] {
|
||||
return t, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
+204
-168
@@ -20,12 +20,12 @@ import (
|
||||
|
||||
// recordingNotifier ловит события пинга (Notify асинхронен — через канал).
|
||||
type notifyEvent struct {
|
||||
id int64
|
||||
id string
|
||||
ev 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}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func waitNotify(t *testing.T, n *recordingNotifier) notifyEvent {
|
||||
|
||||
func TestNotifier_FiresOnReview(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
st.put(completedDownload("1"))
|
||||
qb := &fakeQbt{
|
||||
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
|
||||
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)}
|
||||
w.SetNotifier(n)
|
||||
|
||||
w.recognizeOne(context.Background(), 1)
|
||||
w.recognizeOne(context.Background(), "1")
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -64,11 +64,11 @@ func TestNotifier_FiresOnDone(t *testing.T) {
|
||||
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestScanner_FiresOnDone(t *testing.T) {
|
||||
s := &recordingScanner{ch: make(chan struct{}, 4)}
|
||||
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)
|
||||
}
|
||||
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.State = store.StateReverted
|
||||
return d
|
||||
@@ -105,90 +105,90 @@ func revertedDownload(id int64) *store.Download {
|
||||
|
||||
func TestRelink_RevertedToRecognizing(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(revertedDownload(1))
|
||||
st.put(revertedDownload("1"))
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
|
||||
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)
|
||||
}
|
||||
if st.downloads[1].State != store.StateRecognizing {
|
||||
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateRecognizing {
|
||||
t.Fatalf("state = %q, want recognizing", st.downloads["1"].State)
|
||||
}
|
||||
if st.overrides[1][ovrForceReview] != "1" {
|
||||
t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview])
|
||||
if st.overrides["1"][ovrForceReview] != "1" {
|
||||
t.Errorf("force_review override = %q, want 1", st.overrides["1"][ovrForceReview])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelink_CancelledToRecognizing(t *testing.T) {
|
||||
st := newMemStore()
|
||||
d := revertedDownload(1)
|
||||
d := revertedDownload("1")
|
||||
d.State = store.StateCancelled
|
||||
st.put(d)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
|
||||
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)
|
||||
}
|
||||
if st.downloads[1].State != store.StateRecognizing {
|
||||
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateRecognizing {
|
||||
t.Fatalf("state = %q, want recognizing", st.downloads["1"].State)
|
||||
}
|
||||
if st.overrides[1][ovrForceReview] != "1" {
|
||||
t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview])
|
||||
if st.overrides["1"][ovrForceReview] != "1" {
|
||||
t.Errorf("force_review override = %q, want 1", st.overrides["1"][ovrForceReview])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelink_RejectsActiveState(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1)) // не reverted/cancelled
|
||||
st.put(completedDownload("1")) // не reverted/cancelled
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRerecognize_ReviewToRecognizing(t *testing.T) {
|
||||
st := newMemStore()
|
||||
d := completedDownload(1)
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
|
||||
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)
|
||||
}
|
||||
if st.downloads[1].State != store.StateRecognizing {
|
||||
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateRecognizing {
|
||||
t.Fatalf("state = %q, want recognizing", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRerecognize_RejectsNonReview(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1)) // completed, не review/deferred
|
||||
st.put(completedDownload("1")) // completed, не review/deferred
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelink_TorrentMissing(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(revertedDownload(1))
|
||||
st.put(revertedDownload("1"))
|
||||
qb := &fakeQbt{torrents: nil} // раздачи в qBittorrent нет
|
||||
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")
|
||||
}
|
||||
// Preflight приводит состояние к реальности: источника нет и цели нет
|
||||
// (reverted — ссылки сняты) → deleted (см. state-reconciliation).
|
||||
if st.downloads[1].State != store.StateDeleted {
|
||||
t.Errorf("state = %q, want deleted (preflight привёл к реальности)", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateDeleted {
|
||||
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) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
// Готовим состояние «как после Relink»: reverted, force_review выставлен.
|
||||
f.st.downloads[1].State = store.StateReverted
|
||||
_ = f.st.SetOverride(context.Background(), 1, ovrForceReview, "1")
|
||||
f.st.downloads["1"].State = store.StateReverted
|
||||
_ = f.st.SetOverride(context.Background(), "1", ovrForceReview, "1")
|
||||
|
||||
auto := seriesResult()
|
||||
auto.Decision.Auto = true
|
||||
auto.Match = &recognize.Match{Provider: "tvdb", ProviderID: "42"}
|
||||
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)
|
||||
}
|
||||
f.w.recognizeOne(context.Background(), 1)
|
||||
f.w.recognizeOne(context.Background(), "1")
|
||||
|
||||
if f.st.downloads[1].State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review (авто-раскладка не должна сработать)", f.st.downloads[1].State)
|
||||
if f.st.downloads["1"].State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review (авто-раскладка не должна сработать)", f.st.downloads["1"].State)
|
||||
}
|
||||
if len(f.st.links) != 0 {
|
||||
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.
|
||||
type memStore struct {
|
||||
downloads map[int64]*store.Download
|
||||
downloads map[string]*store.Download
|
||||
recs []*store.Recognition
|
||||
hints map[int64][]string
|
||||
overrides map[int64]map[string]string
|
||||
hints map[string][]string
|
||||
overrides map[string]map[string]string
|
||||
links []store.FileLink
|
||||
candidates []store.MetadataCandidate
|
||||
}
|
||||
|
||||
func newMemStore() *memStore {
|
||||
return &memStore{
|
||||
downloads: map[int64]*store.Download{},
|
||||
hints: map[int64][]string{},
|
||||
overrides: map[int64]map[string]string{},
|
||||
downloads: map[string]*store.Download{},
|
||||
hints: 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
|
||||
}
|
||||
|
||||
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 {
|
||||
if d.Infohash.Valid && d.Infohash.String == infohash {
|
||||
if hasAnyHash(d, hashes) {
|
||||
return true, 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 {
|
||||
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
|
||||
if hasAnyHash(d, hashes) && !d.State.IsTerminal() {
|
||||
cp := *d
|
||||
return &cp, nil
|
||||
}
|
||||
@@ -285,15 +285,51 @@ func (m *memStore) FindActiveByInfohash(_ context.Context, infohash string) (*st
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *memStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
|
||||
id := int64(len(m.downloads) + 1)
|
||||
func (m *memStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
||||
if existing, _ := m.FindActiveByInfohash(ctx, hashes...); existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
id := itoa(len(m.downloads) + 1)
|
||||
cp := *d
|
||||
cp.ID = id
|
||||
for _, h := range hashes {
|
||||
h = store.NormalizeHash(h)
|
||||
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
||||
}
|
||||
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]
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
@@ -302,7 +338,7 @@ func (m *memStore) GetDownload(_ context.Context, id int64) (*store.Download, er
|
||||
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.State = st
|
||||
d.ErrorCode = store.NullString(code)
|
||||
@@ -310,28 +346,28 @@ func (m *memStore) SetDownloadState(_ context.Context, id int64, st store.State,
|
||||
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 {
|
||||
d.SourceMissCount = n
|
||||
}
|
||||
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 {
|
||||
d.SourceAddedAt = store.NullString(store.FormatTime(t))
|
||||
}
|
||||
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 {
|
||||
if e.DownloadID == r.DownloadID {
|
||||
e.IsCurrent = false
|
||||
}
|
||||
}
|
||||
cp := *r
|
||||
cp.ID = int64(len(m.recs) + 1)
|
||||
cp.ID = itoa(len(m.recs) + 1)
|
||||
cp.IsCurrent = true
|
||||
cp.AttemptNo = 1
|
||||
for _, e := range m.recs {
|
||||
@@ -345,7 +381,7 @@ func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, re
|
||||
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 {
|
||||
if e.DownloadID == downloadID && e.IsCurrent {
|
||||
cp := *e
|
||||
@@ -355,20 +391,20 @@ func (m *memStore) GetCurrentRecognition(_ context.Context, downloadID int64) (*
|
||||
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)
|
||||
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 {
|
||||
m.overrides[id] = map[string]string{}
|
||||
}
|
||||
m.overrides[id][field] = value
|
||||
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
|
||||
}
|
||||
|
||||
@@ -376,7 +412,7 @@ func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) er
|
||||
m.links = append(m.links, links...)
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
@@ -395,7 +431,7 @@ func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID int64, ds
|
||||
}
|
||||
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-- {
|
||||
if m.links[i].DownloadID == id {
|
||||
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 {
|
||||
for _, c := range cands {
|
||||
c.ID = int64(len(m.candidates) + 1)
|
||||
c.ID = itoa(len(m.candidates) + 1)
|
||||
m.candidates = append(m.candidates, c)
|
||||
}
|
||||
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
|
||||
for _, c := range m.candidates {
|
||||
if c.RecognitionID == recID {
|
||||
@@ -439,7 +475,7 @@ func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID int64) (
|
||||
}
|
||||
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 {
|
||||
if m.candidates[i].ID == id {
|
||||
cp := m.candidates[i]
|
||||
@@ -448,7 +484,7 @@ func (m *memStore) GetCandidate(_ context.Context, id int64) (*store.MetadataCan
|
||||
}
|
||||
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 {
|
||||
if m.candidates[i].RecognitionID == recID {
|
||||
m.candidates[i].Chosen = m.candidates[i].ID == id
|
||||
@@ -501,10 +537,10 @@ func itoa(n int) string {
|
||||
|
||||
const ihTest = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
|
||||
func completedDownload(id int64) *store.Download {
|
||||
func completedDownload(id string) *store.Download {
|
||||
return &store.Download{
|
||||
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",
|
||||
}
|
||||
}
|
||||
@@ -526,7 +562,7 @@ func seriesResult() recognize.Result {
|
||||
|
||||
func TestRecognizeOne_CompletedToReview(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
st.put(completedDownload("1"))
|
||||
qb := &fakeQbt{
|
||||
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}},
|
||||
@@ -534,12 +570,12 @@ func TestRecognizeOne_CompletedToReview(t *testing.T) {
|
||||
rec := &fakeRecognizer{result: seriesResult()}
|
||||
w := testWorkerWith(st, qb, rec, nil)
|
||||
|
||||
w.recognizeOne(context.Background(), 1)
|
||||
w.recognizeOne(context.Background(), "1")
|
||||
|
||||
if st.downloads[1].State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateReview {
|
||||
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" {
|
||||
t.Fatalf("recognition = %+v", cur)
|
||||
}
|
||||
@@ -554,7 +590,7 @@ func TestRecognizeOne_CompletedToReview(t *testing.T) {
|
||||
// распознавание падало с «torrent not found in qBittorrent».
|
||||
func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
st.put(completedDownload("1"))
|
||||
qb := &fakeQbt{
|
||||
torrents: []qbt.Torrent{{
|
||||
Hash: ihTest, Name: "ThePitt", SavePath: "/d",
|
||||
@@ -565,14 +601,14 @@ func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
|
||||
rec := &fakeRecognizer{result: seriesResult()}
|
||||
w := testWorkerWith(st, qb, rec, nil)
|
||||
|
||||
w.recognizeOne(context.Background(), 1)
|
||||
w.recognizeOne(context.Background(), "1")
|
||||
|
||||
if st.downloads[1].State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review", st.downloads["1"].State)
|
||||
}
|
||||
// Recognizer вернул бы Title="Show" только если торрент найден по infohash;
|
||||
// при потере (фильтр по категории) был бы пустой план с причиной «not found».
|
||||
cur, _ := st.GetCurrentRecognition(context.Background(), 1)
|
||||
cur, _ := st.GetCurrentRecognition(context.Background(), "1")
|
||||
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",
|
||||
func() string {
|
||||
@@ -586,40 +622,40 @@ func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
|
||||
|
||||
func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
st.put(completedDownload("1"))
|
||||
qb := &fakeQbt{
|
||||
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
|
||||
files: []qbt.File{{Name: "Show/e1.mkv", Size: 100}},
|
||||
}
|
||||
// Во время вызова LLM задачу отменяют.
|
||||
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.recognizeOne(context.Background(), 1)
|
||||
w.recognizeOne(context.Background(), "1")
|
||||
|
||||
if st.downloads[1].State != store.StateCancelled {
|
||||
t.Errorf("state = %q, want cancelled (result discarded)", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateCancelled {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeOne_SignalsErrorToReview(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
st.put(completedDownload("1"))
|
||||
qb := &fakeQbt{torrents: nil} // торрент пропал
|
||||
rec := &fakeRecognizer{result: seriesResult()}
|
||||
w := testWorkerWith(st, qb, rec, nil)
|
||||
|
||||
w.recognizeOne(context.Background(), 1)
|
||||
w.recognizeOne(context.Background(), "1")
|
||||
|
||||
if st.downloads[1].State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateReview {
|
||||
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 {
|
||||
t.Fatal("expected review with reason")
|
||||
}
|
||||
@@ -627,82 +663,82 @@ func TestRecognizeOne_SignalsErrorToReview(t *testing.T) {
|
||||
|
||||
func TestRefine_AddsHintAndRerecognizes(t *testing.T) {
|
||||
st := newMemStore()
|
||||
d := completedDownload(1)
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
|
||||
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)
|
||||
}
|
||||
if st.downloads[1].State != store.StateRecognizing {
|
||||
t.Errorf("state = %q, want recognizing", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateRecognizing {
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetType(t *testing.T) {
|
||||
st := newMemStore()
|
||||
d := completedDownload(1)
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
|
||||
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)
|
||||
}
|
||||
if st.overrides[1][ovrMediaType] != "series" {
|
||||
t.Errorf("override = %v", st.overrides[1])
|
||||
if st.overrides["1"][ovrMediaType] != "series" {
|
||||
t.Errorf("override = %v", st.overrides["1"])
|
||||
}
|
||||
if st.downloads[1].State != store.StateRecognizing {
|
||||
t.Errorf("state = %q, want recognizing", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateRecognizing {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoreFile(t *testing.T) {
|
||||
st := newMemStore()
|
||||
d := completedDownload(1)
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
ignored := parseIgnored(st.overrides[1][ovrIgnoredFiles])
|
||||
ignored := parseIgnored(st.overrides["1"][ovrIgnoredFiles])
|
||||
if len(ignored) != 1 || ignored[0] != "Show/sample.mkv" {
|
||||
t.Errorf("ignored = %v", ignored)
|
||||
}
|
||||
if st.downloads[1].State != store.StateReview {
|
||||
t.Errorf("ignore must keep review, got %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateReview {
|
||||
t.Errorf("ignore must keep review, got %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefer(t *testing.T) {
|
||||
st := newMemStore()
|
||||
d := completedDownload(1)
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
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)
|
||||
}
|
||||
if st.downloads[1].State != store.StateDeferred {
|
||||
t.Errorf("state = %q, want deferred", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateDeferred {
|
||||
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()
|
||||
d := completedDownload(1)
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
planJSON, _ := json.Marshal(plan)
|
||||
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"}}}
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
if f.st.downloads[1].State != store.StateDone {
|
||||
t.Fatalf("state = %q, want done", f.st.downloads[1].State)
|
||||
if f.st.downloads["1"].State != store.StateDone {
|
||||
t.Fatalf("state = %q, want done", f.st.downloads["1"].State)
|
||||
}
|
||||
if len(f.st.links) != 2 {
|
||||
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,
|
||||
})
|
||||
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)
|
||||
}
|
||||
if len(f.st.links) != 2 { // sample пропущен
|
||||
@@ -798,12 +834,12 @@ func TestApply_CollisionStaysReview(t *testing.T) {
|
||||
_ = os.MkdirAll(filepath.Dir(dst), 0o755)
|
||||
_ = os.WriteFile(dst, []byte("foreign"), 0o644)
|
||||
|
||||
err := f.w.Apply(context.Background(), 1)
|
||||
err := f.w.Apply(context.Background(), "1")
|
||||
if err == nil {
|
||||
t.Fatal("want collision error")
|
||||
}
|
||||
if f.st.downloads[1].State != store.StateReview {
|
||||
t.Errorf("state = %q, want review after collision", f.st.downloads[1].State)
|
||||
if f.st.downloads["1"].State != store.StateReview {
|
||||
t.Errorf("state = %q, want review after collision", f.st.downloads["1"].State)
|
||||
}
|
||||
b, _ := os.ReadFile(dst)
|
||||
if string(b) != "foreign" {
|
||||
@@ -817,20 +853,20 @@ func TestApply_SupersedesForeignOwnerOfPath(t *testing.T) {
|
||||
plan := seriesResult().Plan
|
||||
f := newApplyFixture(t, plan)
|
||||
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{
|
||||
DownloadID: 2, ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
|
||||
DownloadID: "2", ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
|
||||
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)
|
||||
}
|
||||
|
||||
// Чужая ссылка на перехваченный путь — superseded.
|
||||
var foreign *store.FileLink
|
||||
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]
|
||||
}
|
||||
}
|
||||
@@ -839,12 +875,12 @@ func TestApply_SupersedesForeignOwnerOfPath(t *testing.T) {
|
||||
}
|
||||
// Свои ссылки (id=1) не тронуты — download_id != self.
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Прежняя загрузка больше не владеет путём → цель отсутствует.
|
||||
present, err := f.w.targetPresent(context.Background(), 2)
|
||||
present, err := f.w.targetPresent(context.Background(), "2")
|
||||
if err != nil {
|
||||
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")
|
||||
_ = os.MkdirAll(filepath.Dir(e01), 0o755)
|
||||
_ = 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{
|
||||
DownloadID: 2, ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
|
||||
DownloadID: "2", ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -880,7 +916,7 @@ func TestApply_CollisionKeepsForeignOwner(t *testing.T) {
|
||||
func TestUndo_RevertsLinks(t *testing.T) {
|
||||
plan := seriesResult().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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if f.st.downloads[1].State != store.StateReverted {
|
||||
t.Errorf("state = %q, want reverted", f.st.downloads[1].State)
|
||||
if f.st.downloads["1"].State != store.StateReverted {
|
||||
t.Errorf("state = %q, want reverted", f.st.downloads["1"].State)
|
||||
}
|
||||
if _, err := os.Stat(dst); !os.IsNotExist(err) {
|
||||
t.Errorf("link must be removed: %v", err)
|
||||
@@ -909,9 +945,9 @@ func TestUndo_RevertsLinks(t *testing.T) {
|
||||
func TestReviewData(t *testing.T) {
|
||||
plan := seriesResult().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 {
|
||||
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)
|
||||
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
st.put(completedDownload("1"))
|
||||
qb := &fakeQbt{
|
||||
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}},
|
||||
@@ -976,10 +1012,10 @@ func TestRecognizeOne_AutoApplies(t *testing.T) {
|
||||
}}
|
||||
w := testWorkerWith(st, qb, rec, lay)
|
||||
|
||||
w.recognizeOne(context.Background(), 1)
|
||||
w.recognizeOne(context.Background(), "1")
|
||||
|
||||
if st.downloads[1].State != store.StateDone {
|
||||
t.Fatalf("state = %q, want done (auto)", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateDone {
|
||||
t.Fatalf("state = %q, want done (auto)", st.downloads["1"].State)
|
||||
}
|
||||
// Provider-тег попал в имя папки.
|
||||
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].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)
|
||||
}
|
||||
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) {
|
||||
t.Helper()
|
||||
st := newMemStore()
|
||||
d := completedDownload(1)
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
planJSON, _ := json.Marshal(recognize.Plan{Type: recognize.MediaSeries, Title: "Догадка", Year: 2000})
|
||||
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"),
|
||||
})
|
||||
cand.RecognitionID = 1
|
||||
cand.RecognitionID = "1"
|
||||
_ = st.CreateCandidates(context.Background(), []store.MetadataCandidate{cand})
|
||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
||||
return w, st
|
||||
@@ -1042,7 +1078,7 @@ func reviewWithCandidate(t *testing.T, cand store.MetadataCandidate) (*Worker, *
|
||||
|
||||
func TestRecognizeOne_PersistsCandidates(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
st.put(completedDownload("1"))
|
||||
qb := &fakeQbt{
|
||||
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
|
||||
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.recognizeOne(context.Background(), 1)
|
||||
w.recognizeOne(context.Background(), "1")
|
||||
|
||||
if len(st.candidates) != 2 {
|
||||
t.Fatalf("candidates = %d, want 2", len(st.candidates))
|
||||
@@ -1080,10 +1116,10 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
|
||||
})
|
||||
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)
|
||||
}
|
||||
ov := st.overrides[1]
|
||||
ov := st.overrides["1"]
|
||||
if ov[ovrProvider] != "tvdb" || ov[ovrProviderID] != "269613" ||
|
||||
ov[ovrTitle] != "Fargo" || ov[ovrYear] != "2014" {
|
||||
t.Errorf("overrides = %v", ov)
|
||||
@@ -1092,7 +1128,7 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
|
||||
t.Error("кандидат не помечен выбранным")
|
||||
}
|
||||
// Эффективный план берёт каноническое имя/год и тег [tvdbid-...].
|
||||
plan, tag, err := w.effectivePlan(context.Background(), 1)
|
||||
plan, tag, err := w.effectivePlan(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("effectivePlan: %v", err)
|
||||
}
|
||||
@@ -1106,38 +1142,38 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
|
||||
|
||||
func TestChooseCandidate_RejectsForeign(t *testing.T) {
|
||||
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("чужой кандидат должен отклоняться")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderID(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
if st.overrides[1][ovrProvider] != "tmdb" || st.overrides[1][ovrProviderID] != "603" {
|
||||
t.Errorf("overrides = %v", st.overrides[1])
|
||||
if st.overrides["1"][ovrProvider] != "tmdb" || st.overrides["1"][ovrProviderID] != "603" {
|
||||
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("недопустимый провайдер должен отклоняться")
|
||||
}
|
||||
if err := w.SetProviderID(context.Background(), 1, "tmdb", ""); err == nil {
|
||||
if err := w.SetProviderID(context.Background(), "1", "tmdb", ""); err == nil {
|
||||
t.Error("пустой id должен отклоняться")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearProvider(t *testing.T) {
|
||||
w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
|
||||
_ = st.SetOverride(context.Background(), 1, ovrProvider, "tvdb")
|
||||
if err := w.ClearProvider(context.Background(), 1); err != nil {
|
||||
_ = st.SetOverride(context.Background(), "1", ovrProvider, "tvdb")
|
||||
if err := w.ClearProvider(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("ClearProvider: %v", err)
|
||||
}
|
||||
if st.overrides[1][ovrProvider] != "none" {
|
||||
t.Errorf("provider override = %q, want none", st.overrides[1][ovrProvider])
|
||||
if st.overrides["1"][ovrProvider] != "none" {
|
||||
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 != "" {
|
||||
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"),
|
||||
})
|
||||
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)
|
||||
}
|
||||
rd, err := w.ReviewData(context.Background(), 1)
|
||||
rd, err := w.ReviewData(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewData: %v", err)
|
||||
}
|
||||
@@ -1169,7 +1205,7 @@ func TestReviewData_IncludesCandidates(t *testing.T) {
|
||||
func TestToStoreCandidates_URL(t *testing.T) {
|
||||
// Кандидат с URL: URL должен быть проброшен как непустой NullString.
|
||||
// Кандидат без 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: "tvdb", ID: "1", Title: "Without URL", URL: ""},
|
||||
})
|
||||
|
||||
+93
-42
@@ -13,12 +13,15 @@ package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
@@ -39,34 +42,35 @@ const (
|
||||
type Store interface {
|
||||
ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error)
|
||||
ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error)
|
||||
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
||||
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
|
||||
SetSourceMissCount(ctx context.Context, id int64, n int) error
|
||||
SetSourceAddedAt(ctx context.Context, id int64, t time.Time) error
|
||||
GetDownload(ctx context.Context, id string) (*store.Download, error)
|
||||
SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
||||
SetSourceMissCount(ctx context.Context, id string, n int) error
|
||||
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
|
||||
|
||||
// Discovery (усыновление раздач по категории/тегу).
|
||||
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
|
||||
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
|
||||
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
|
||||
// Идентичность/инвариант «одна активная загрузка на infohash».
|
||||
ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error)
|
||||
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error)
|
||||
ActivateIfNoOtherActive(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
||||
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
||||
|
||||
// Ф3: распознавание, ревью, раскладка.
|
||||
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (int64, error)
|
||||
GetCurrentRecognition(ctx context.Context, downloadID int64) (*store.Recognition, error)
|
||||
AddHint(ctx context.Context, downloadID int64, text string) error
|
||||
ListHints(ctx context.Context, downloadID int64) ([]string, error)
|
||||
SetOverride(ctx context.Context, downloadID int64, field, value string) error
|
||||
ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error)
|
||||
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (string, error)
|
||||
GetCurrentRecognition(ctx context.Context, downloadID string) (*store.Recognition, error)
|
||||
AddHint(ctx context.Context, downloadID string, text string) error
|
||||
ListHints(ctx context.Context, downloadID string) ([]string, error)
|
||||
SetOverride(ctx context.Context, downloadID string, field, value string) error
|
||||
ListOverrides(ctx context.Context, downloadID string) (map[string]string, error)
|
||||
CreateFileLinks(ctx context.Context, links []store.FileLink) error
|
||||
SupersedeForeignLinks(ctx context.Context, downloadID int64, dstPaths []string) error
|
||||
LatestBatchID(ctx context.Context, downloadID int64) (string, error)
|
||||
SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error
|
||||
LatestBatchID(ctx context.Context, downloadID string) (string, error)
|
||||
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
|
||||
DeleteFileLinksByBatch(ctx context.Context, batchID string) error
|
||||
|
||||
// Кандидаты базы метаданных (ручной выбор в review).
|
||||
CreateCandidates(ctx context.Context, cands []store.MetadataCandidate) error
|
||||
ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]store.MetadataCandidate, error)
|
||||
GetCandidate(ctx context.Context, id int64) (*store.MetadataCandidate, error)
|
||||
SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error
|
||||
ListCandidatesByRecognition(ctx context.Context, recognitionID string) ([]store.MetadataCandidate, error)
|
||||
GetCandidate(ctx context.Context, id string) (*store.MetadataCandidate, error)
|
||||
SetCandidateChosen(ctx context.Context, recognitionID, candidateID string) error
|
||||
}
|
||||
|
||||
// QBittorrent — нужная worker часть клиента qBittorrent.
|
||||
@@ -111,7 +115,7 @@ const (
|
||||
|
||||
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
|
||||
type Notifier interface {
|
||||
Notify(ctx context.Context, downloadID int64, event NotifyEvent)
|
||||
Notify(ctx context.Context, downloadID string, event NotifyEvent)
|
||||
}
|
||||
|
||||
// Scanner — триггер пересканирования медиатеки Jellyfin. Вызывается
|
||||
@@ -196,7 +200,7 @@ type Worker struct {
|
||||
// время последнего пинга). Мерцающий stalled-торрент колеблется
|
||||
// stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память
|
||||
// процесса: при рестарте дебаунс сбрасывается — допустимо. Доступ под w.mu.
|
||||
failNotified map[int64]time.Time
|
||||
failNotified map[string]time.Time
|
||||
}
|
||||
|
||||
// failNotifyDebounce — минимальный интервал между уведомлениями о падении
|
||||
@@ -221,7 +225,7 @@ func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log
|
||||
log: log,
|
||||
now: time.Now,
|
||||
newID: defaultBatchID,
|
||||
failNotified: map[int64]time.Time{},
|
||||
failNotified: map[string]time.Time{},
|
||||
live: map[string]Live{},
|
||||
}
|
||||
}
|
||||
@@ -246,15 +250,16 @@ func (w *Worker) setLive(snap map[string]Live) {
|
||||
w.liveMu.Unlock()
|
||||
}
|
||||
|
||||
// defaultBatchID — уникальный идентификатор батча раскладки.
|
||||
// defaultBatchID — идентификатор батча раскладки (ULID, единая точка
|
||||
// генерации id — internal/ident; сортируем по времени, удобен в логах).
|
||||
func defaultBatchID() string {
|
||||
return fmt.Sprintf("b-%d", time.Now().UnixNano())
|
||||
return ident.NewID()
|
||||
}
|
||||
|
||||
// scoped кладёт в ctx scoped-логгер загрузки (capability + download_id
|
||||
// [+ infohash]); стадии и внешние клиенты достают его из ctx и дописывают эти
|
||||
// ключи на каждую запись сами — без ручного доклеивания download_id.
|
||||
func (w *Worker) scoped(ctx context.Context, capability string, id int64, infohash string) context.Context {
|
||||
func (w *Worker) scoped(ctx context.Context, capability string, id string, infohash string) context.Context {
|
||||
log := w.log.With("capability", capability, "download_id", id)
|
||||
if infohash != "" {
|
||||
log = log.With("infohash", infohash)
|
||||
@@ -325,15 +330,16 @@ func (w *Worker) Poll(ctx context.Context) error {
|
||||
return fmt.Errorf("poll: list active: %w", err)
|
||||
}
|
||||
for _, d := range active {
|
||||
if !d.Infohash.Valid {
|
||||
if len(d.Infohashes) == 0 {
|
||||
continue // нечем сопоставить (в Ф1 не случается: magnet всегда с infohash)
|
||||
}
|
||||
t, ok := byHash[strings.ToLower(d.Infohash.String)]
|
||||
t, ok := torrentFor(d, byHash)
|
||||
if !ok {
|
||||
w.log.Warn("active download not found in qbittorrent",
|
||||
"capability", capIngest, "download_id", d.ID, "infohash", d.Infohash.String)
|
||||
"capability", capIngest, "download_id", d.ID, "infohash", d.PrimaryInfohash())
|
||||
continue
|
||||
}
|
||||
w.captureInfohashes(ctx, d, t)
|
||||
w.captureSourceAddedAt(ctx, d, t)
|
||||
w.reconcile(ctx, d, t)
|
||||
}
|
||||
@@ -351,7 +357,7 @@ func (w *Worker) Poll(ctx context.Context) error {
|
||||
// reconcile двигает одну задачу по состоянию её торрента. Вызывается под
|
||||
// w.mu.
|
||||
func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent) {
|
||||
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
||||
switch classify(t.State) {
|
||||
case classReady:
|
||||
w.transition(ctx, d, store.StateCompleted, "", "")
|
||||
@@ -397,6 +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 (надёжный базис,
|
||||
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
|
||||
// отдал added_on.
|
||||
@@ -453,7 +492,7 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
|
||||
// Скан Jellyfin — неблокирующе и вне w.mu, в фоновом ctx со scoped-логгером
|
||||
// (download_id для корреляции ext.*-записи клиента). Недоступность Jellyfin
|
||||
// на задачу не влияет; ошибку вызова логирует сам клиент (ext.*), здесь гасим.
|
||||
gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.Infohash.String)
|
||||
gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.PrimaryInfohash())
|
||||
go func() { _ = w.scanner.RefreshLibraries(gctx) }()
|
||||
}
|
||||
}
|
||||
@@ -462,7 +501,7 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
|
||||
// (мерцающий stalled-торрент: stuck↔downloading), чтобы не спамить. Вызывается
|
||||
// под w.mu. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы
|
||||
// флаппинг.
|
||||
func (w *Worker) shouldNotifyFail(id int64) bool {
|
||||
func (w *Worker) shouldNotifyFail(id string) bool {
|
||||
now := w.now()
|
||||
if last, ok := w.failNotified[id]; ok && now.Sub(last) < failNotifyDebounce {
|
||||
return false
|
||||
@@ -479,7 +518,7 @@ func (w *Worker) shouldNotifyFail(id int64) bool {
|
||||
|
||||
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
|
||||
// раздачу (источник неприкосновенен).
|
||||
func (w *Worker) Cancel(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Cancel(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -488,18 +527,18 @@ func (w *Worker) Cancel(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("cancel: %w", err)
|
||||
}
|
||||
if d.State.IsTerminal() {
|
||||
return fmt.Errorf("cancel: download %d is already terminal (%s)", id, d.State)
|
||||
return fmt.Errorf("cancel: download %s is already terminal (%s)", id, d.State)
|
||||
}
|
||||
if err := w.store.SetDownloadState(ctx, id, store.StateCancelled, "", ""); err != nil {
|
||||
return fmt.Errorf("cancel: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("download cancelled", "from", d.State)
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download cancelled", "from", d.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в
|
||||
// qBittorrent и возвращает в downloading.
|
||||
func (w *Worker) Retry(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Retry(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -508,32 +547,44 @@ func (w *Worker) Retry(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
if d.State != store.StateFailed && d.State != store.StateStuck {
|
||||
return fmt.Errorf("retry: download %d is %s, only failed/stuck are retriable", id, d.State)
|
||||
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable", id, d.State)
|
||||
}
|
||||
// Если раздача уже жива в qBittorrent — перецепляемся к ней, повторный Add
|
||||
// не нужен (и вреден: вслепую дублировал бы торрент). Add — только когда
|
||||
// источника в qBittorrent нет. Базис таймаута берётся от added_on, поэтому
|
||||
// возврат в downloading не роняет задачу снова на ближайшем тике.
|
||||
alive := false
|
||||
if d.Infohash.Valid {
|
||||
_, alive, err = w.torrentByInfohash(ctx, d.Infohash.String)
|
||||
if hashes := d.HashList(); len(hashes) > 0 {
|
||||
_, alive, err = w.torrentByInfohash(ctx, hashes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
}
|
||||
// Гард инварианта — ДО побочного эффекта в qBittorrent: пока задача лежала
|
||||
// в failed, тем же infohash могла завладеть другая активная задача — тогда
|
||||
// отказываем, не добавив торрент повторно (см. design ulid-identity, D4).
|
||||
if err := w.store.ActivateIfNoOtherActive(ctx, id, store.StateDownloading, "", ""); err != nil {
|
||||
if errors.Is(err, store.ErrInfohashTaken) {
|
||||
return fmt.Errorf("retry: для этого торрента уже есть другая активная задача: %w", ErrConflict)
|
||||
}
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
if !alive && d.SourceType == store.SourceMagnet {
|
||||
if err := w.qbt.Add(ctx, qbt.AddRequest{
|
||||
URLs: []string{d.SourceRef},
|
||||
Category: w.cfg.Category,
|
||||
SavePath: w.cfg.SavePath,
|
||||
}); err != nil {
|
||||
// Активация уже прошла — откатываем задачу в прежнее состояние,
|
||||
// чтобы не оставить «качающуюся» задачу без раздачи в qBittorrent.
|
||||
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
|
||||
w.log.Error("retry rollback failed",
|
||||
"capability", capReview, "download_id", id, "error", rbErr)
|
||||
}
|
||||
return fmt.Errorf("retry: add to qbittorrent: %w", err)
|
||||
}
|
||||
}
|
||||
if err := w.store.SetDownloadState(ctx, id, store.StateDownloading, "", ""); err != nil {
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("download retried", "from", d.State)
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download retried", "from", d.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+165
-47
@@ -2,6 +2,7 @@ package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -20,12 +21,12 @@ const (
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
downloads map[int64]*store.Download
|
||||
downloads map[string]*store.Download
|
||||
transitions []transition
|
||||
}
|
||||
|
||||
type transition struct {
|
||||
id int64
|
||||
id string
|
||||
state store.State
|
||||
}
|
||||
|
||||
@@ -58,27 +59,39 @@ func (f *fakeStore) ListRecoverable(_ context.Context, codes ...string) ([]store
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetDownload(_ context.Context, id int64) (*store.Download, error) {
|
||||
func (f *fakeStore) GetDownload(_ context.Context, id string) (*store.Download, error) {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("download %d not found", id)
|
||||
return nil, fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
cp := *d
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
|
||||
// hasAnyHash сообщает, владеет ли загрузка любым из hashes.
|
||||
func hasAnyHash(d *store.Download, hashes []string) bool {
|
||||
for _, own := range d.Infohashes {
|
||||
for _, h := range hashes {
|
||||
if own.Infohash == store.NormalizeHash(h) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *fakeStore) ExistsByInfohash(_ context.Context, hashes ...string) (bool, error) {
|
||||
for _, d := range f.downloads {
|
||||
if d.Infohash.Valid && d.Infohash.String == infohash {
|
||||
if hasAnyHash(d, hashes) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
|
||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*store.Download, error) {
|
||||
for _, d := range f.downloads {
|
||||
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
|
||||
if hasAnyHash(d, hashes) && !d.State.IsTerminal() {
|
||||
cp := *d
|
||||
return &cp, nil
|
||||
}
|
||||
@@ -86,18 +99,62 @@ func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*s
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
|
||||
id := int64(len(f.downloads) + 1)
|
||||
func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
||||
if existing, _ := f.FindActiveByInfohash(ctx, hashes...); existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
id := fmt.Sprintf("%d", len(f.downloads)+1)
|
||||
cp := *d
|
||||
cp.ID = id
|
||||
for _, h := range hashes {
|
||||
h = store.NormalizeHash(h)
|
||||
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
||||
}
|
||||
f.downloads[id] = &cp
|
||||
return id, nil
|
||||
d.ID = id
|
||||
d.Infohashes = cp.Infohashes
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
|
||||
func (f *fakeStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %d not found", id)
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
for _, other := range f.downloads {
|
||||
if other.ID != id && !other.State.IsTerminal() && hasAnyHash(other, hashList(d)) {
|
||||
return fmt.Errorf("activate %s: %w", id, store.ErrInfohashTaken)
|
||||
}
|
||||
}
|
||||
return f.SetDownloadState(ctx, id, st, code, msg)
|
||||
}
|
||||
|
||||
func hashList(d *store.Download) []string {
|
||||
out := make([]string, len(d.Infohashes))
|
||||
for i, h := range d.Infohashes {
|
||||
out[i] = h.Infohash
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
for _, h := range hashes {
|
||||
h = store.NormalizeHash(h)
|
||||
if !hasAnyHash(d, []string{h}) {
|
||||
d.Infohashes = append(d.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
d.State = st
|
||||
d.ErrorCode = store.NullString(code)
|
||||
@@ -106,19 +163,19 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetSourceMissCount(_ context.Context, id int64, n int) error {
|
||||
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %d not found", id)
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
d.SourceMissCount = n
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) error {
|
||||
func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %d not found", id)
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
if !d.SourceAddedAt.Valid { // гард как в store: пишем однократно
|
||||
d.SourceAddedAt = store.NullString(store.FormatTime(t))
|
||||
@@ -128,23 +185,23 @@ func (f *fakeStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) e
|
||||
|
||||
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
|
||||
|
||||
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) {
|
||||
return 0, nil
|
||||
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ int64) (*store.Recognition, error) {
|
||||
func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ string) (*store.Recognition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) AddHint(_ context.Context, _ int64, _ string) error { return nil }
|
||||
func (f *fakeStore) ListHints(_ context.Context, _ int64) ([]string, error) { return nil, nil }
|
||||
func (f *fakeStore) SetOverride(_ context.Context, _ int64, _, _ string) error { return nil }
|
||||
func (f *fakeStore) ListOverrides(_ context.Context, _ int64) (map[string]string, error) {
|
||||
func (f *fakeStore) AddHint(_ context.Context, _ string, _ string) error { return nil }
|
||||
func (f *fakeStore) ListHints(_ context.Context, _ string) ([]string, error) { return nil, nil }
|
||||
func (f *fakeStore) SetOverride(_ context.Context, _ string, _, _ string) error { return nil }
|
||||
func (f *fakeStore) ListOverrides(_ context.Context, _ string) (map[string]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil }
|
||||
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ int64, _ []string) error {
|
||||
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ string, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) LatestBatchID(_ context.Context, _ int64) (string, error) { return "", nil }
|
||||
func (f *fakeStore) LatestBatchID(_ context.Context, _ string) (string, error) { return "", nil }
|
||||
func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.FileLink, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -152,17 +209,18 @@ func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error {
|
||||
func (f *fakeStore) CreateCandidates(_ context.Context, _ []store.MetadataCandidate) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ int64) ([]store.MetadataCandidate, error) {
|
||||
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ string) ([]store.MetadataCandidate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) GetCandidate(_ context.Context, _ int64) (*store.MetadataCandidate, error) {
|
||||
func (f *fakeStore) GetCandidate(_ context.Context, _ string) (*store.MetadataCandidate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ int64) error { return nil }
|
||||
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
type fakeQbt struct {
|
||||
torrents []qbt.Torrent
|
||||
added []qbt.AddRequest
|
||||
addErr error
|
||||
files []qbt.File
|
||||
}
|
||||
|
||||
@@ -184,6 +242,9 @@ func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, e
|
||||
}
|
||||
|
||||
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
|
||||
if f.addErr != nil {
|
||||
return f.addErr
|
||||
}
|
||||
f.added = append(f.added, ar)
|
||||
return nil
|
||||
}
|
||||
@@ -204,18 +265,28 @@ func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
|
||||
}
|
||||
|
||||
func oneDownloading(infohash, createdAt string) *fakeStore {
|
||||
return &fakeStore{downloads: map[int64]*store.Download{
|
||||
1: {
|
||||
ID: 1,
|
||||
return &fakeStore{downloads: map[string]*store.Download{
|
||||
"1": {
|
||||
ID: "1",
|
||||
State: store.StateDownloading,
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
||||
Infohash: store.NullString(infohash),
|
||||
Infohashes: hashesOf("1", infohash),
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// hashesOf — срез хешей загрузки для литералов фикстур.
|
||||
func hashesOf(id string, hashes ...string) []store.Infohash {
|
||||
out := make([]store.Infohash, 0, len(hashes))
|
||||
for _, h := range hashes {
|
||||
h = store.NormalizeHash(h)
|
||||
out = append(out, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestPollTransitions(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
tests := []struct {
|
||||
@@ -242,7 +313,7 @@ func TestPollTransitions(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := st.downloads[1].State; got != tc.want {
|
||||
if got := st.downloads["1"].State; got != tc.want {
|
||||
t.Errorf("state = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
@@ -257,8 +328,8 @@ func TestPollMatchesByInfohashV2(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateCompleted {
|
||||
t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateCompleted {
|
||||
t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,46 +340,93 @@ func TestPollIgnoresMissingTorrent(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateDownloading {
|
||||
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateDownloading {
|
||||
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancel(t *testing.T) {
|
||||
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
if err := w.Cancel(context.Background(), 1); err != nil {
|
||||
if err := w.Cancel(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Cancel: %v", err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateCancelled {
|
||||
t.Errorf("state = %q, want cancelled", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateCancelled {
|
||||
t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
|
||||
}
|
||||
// Повторная отмена терминальной задачи — ошибка.
|
||||
if err := w.Cancel(context.Background(), 1); err == nil {
|
||||
if err := w.Cancel(context.Background(), "1"); err == nil {
|
||||
t.Error("ожидалась ошибка при отмене терминальной задачи")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetry(t *testing.T) {
|
||||
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||
st.downloads[1].State = store.StateStuck
|
||||
st.downloads["1"].State = store.StateStuck
|
||||
qb := &fakeQbt{}
|
||||
w := newTestWorker(st, qb)
|
||||
if err := w.Retry(context.Background(), 1); err != nil {
|
||||
if err := w.Retry(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Retry: %v", err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateDownloading {
|
||||
t.Errorf("state = %q, want downloading", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateDownloading {
|
||||
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
|
||||
}
|
||||
if len(qb.added) != 1 {
|
||||
t.Errorf("ожидалось повторное добавление в qBittorrent, got %d", len(qb.added))
|
||||
}
|
||||
}
|
||||
|
||||
// Retry при занятом хеше отклоняется ДО побочного эффекта: торрент не
|
||||
// добавляется в qBittorrent повторно.
|
||||
func TestRetryConflictNoAdd(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneDownloading(ih, timeRecent)
|
||||
st.downloads["1"].State = store.StateFailed
|
||||
st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
|
||||
// Хешем владеет другая активная задача.
|
||||
st.downloads["2"] = &store.Download{
|
||||
ID: "2", State: store.StateDownloading, SourceType: store.SourceMagnet,
|
||||
Infohashes: hashesOf("2", ih), CreatedAt: timeRecent,
|
||||
}
|
||||
qb := &fakeQbt{} // торрента в qBittorrent нет — без гарда был бы Add
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
if err := w.Retry(context.Background(), "1"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("ожидался ErrConflict, получили %v", err)
|
||||
}
|
||||
if len(qb.added) != 0 {
|
||||
t.Errorf("торрент добавлен побочным эффектом отклонённого retry: %d Add", len(qb.added))
|
||||
}
|
||||
if st.downloads["1"].State != store.StateFailed {
|
||||
t.Errorf("state = %s, want failed", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
// Если после активации повторный Add в qBittorrent упал — задача
|
||||
// откатывается в прежнее состояние, а не остаётся «качающейся» без раздачи.
|
||||
func TestRetryRollsBackOnAddFailure(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneDownloading(ih, timeRecent)
|
||||
st.downloads["1"].State = store.StateFailed
|
||||
st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
|
||||
qb := &fakeQbt{addErr: fmt.Errorf("connection refused")}
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
if err := w.Retry(context.Background(), "1"); err == nil {
|
||||
t.Fatal("ожидалась ошибка Add")
|
||||
}
|
||||
if st.downloads["1"].State != store.StateFailed {
|
||||
t.Errorf("state = %s, want failed (откат)", st.downloads["1"].State)
|
||||
}
|
||||
if st.downloads["1"].ErrorCode.String != "magnet_timeout" {
|
||||
t.Errorf("error_code = %q, want magnet_timeout (восстановлен)", st.downloads["1"].ErrorCode.String)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryRejectsActive(t *testing.T) {
|
||||
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
if err := w.Retry(context.Background(), 1); err == nil {
|
||||
if err := w.Retry(context.Background(), "1"); err == nil {
|
||||
t.Error("retry активной (downloading) задачи должен отклоняться")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-02
|
||||
@@ -0,0 +1,240 @@
|
||||
## Context
|
||||
|
||||
Идентичность сегодня: `download.id` — INTEGER AUTOINCREMENT (как и у всех
|
||||
шести таблиц), дедуп — `idempotency_key UNIQUE` (= infohash у активных
|
||||
задач). Ключ снимается при терминализации (`SetDownloadState`:
|
||||
`idempotency_key = CASE WHEN terminal THEN NULL ELSE infohash END`) и
|
||||
восстанавливается при возврате из терминала — так обеспечивается инвариант
|
||||
«одна активная задача на infohash» при легальном повторном приёме того же
|
||||
торрента после завершения. Поллинг сопоставляет раздачу по `hash`/
|
||||
`infohash_v1`/`infohash_v2` с одним хранимым `download.infohash`.
|
||||
|
||||
Мотивация и разбор — в proposal и черновике
|
||||
[logical-title-model §5.1](../../../docs/drafts/logical-title-model.md).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- ULID как публичный стабильный ключ всех сущностей домена; единый формат id
|
||||
в БД, URL и логах; grep по голому id находит всё.
|
||||
- Множество инфохэшей загрузки (`download_infohash`) вместо одного столбца;
|
||||
дедуп и сопоставление в поллинге — по любому из хешей.
|
||||
- Упрощение механики активности: убрать снимаемый/восстанавливаемый
|
||||
`idempotency_key`, активность выводится только из `state`.
|
||||
- Конвенция «без числовых PK» для будущих таблиц.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Сходимость папки, merge-раскладка, `state_transition`, сущность title —
|
||||
отдельные change'и (этапность черновика).
|
||||
- Оптимизация производительности БД (домашний масштаб).
|
||||
- Совместимость со старыми числовыми id после миграции (старые URL в
|
||||
истории Telegram/закладках, старые inline-кнопки бота) — не поддерживаем.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. ULID, канонически lowercase
|
||||
|
||||
ULID (`github.com/oklog/ulid/v2`, чистый Go): 128 бит, сортируем по времени
|
||||
(48 бит timestamp), 26 символов Crockford base32 — компактнее UUID, без
|
||||
дефисов (grep/двойной клик в логах), глобально уникален across таблиц.
|
||||
Альтернативы: UUIDv4 — не сортируется; UUIDv7 — эквивалент со статусом RFC,
|
||||
выбрали бы при интеграции с внешней системой, ждущей UUID (таких нет);
|
||||
xid/KSUID — менее распространены без выгоды.
|
||||
|
||||
Канонический вид — **lowercase** (читаемость; спека ULID case-insensitive
|
||||
при декодировании). Единственная точка генерации — хелпер `internal/ident`:
|
||||
`NewID()` (monotonic entropy, `strings.ToLower`), `Parse()` (нормализация
|
||||
регистра + валидация). Id сущностей генерируют Create-методы `store`
|
||||
(сейчас они живут на `LastInsertId` — все переводятся на `ident.NewID()`);
|
||||
`apply_batch_id` генерирует воркер тем же хелпером. Все входные границы
|
||||
(URL, формы — включая `candidate_id` в ревью) прогоняют id через `Parse`
|
||||
до запроса к БД — сравнение в SQLite побайтовое.
|
||||
|
||||
### D2. Хранение: TEXT PK, обычные rowid-таблицы
|
||||
|
||||
TEXT(26), не BLOB(16) — читаемость в `sqlite3` CLI и логах дороже 10 байт.
|
||||
`WITHOUT ROWID` не используем — выгода на нашем масштабе нулевая, а готчи
|
||||
есть. `AUTOINCREMENT` исчезает из схемы полностью.
|
||||
|
||||
### D3. `download_infohash`: составной ключ, а НЕ `UNIQUE(infohash)`
|
||||
|
||||
```sql
|
||||
CREATE TABLE download_infohash (
|
||||
download_id TEXT NOT NULL REFERENCES download (id) ON DELETE CASCADE,
|
||||
infohash TEXT NOT NULL, -- lowercase hex
|
||||
kind TEXT NOT NULL, -- v1 | v2
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (infohash, download_id)
|
||||
);
|
||||
```
|
||||
|
||||
Черновик предлагал `UNIQUE(infohash)` — это **неверно**: спека
|
||||
state-reconciliation гарантирует «повторный приём того же infohash после
|
||||
терминала возможен», то есть один хеш легитимно принадлежит нескольким
|
||||
загрузкам во времени. Глобальная уникальность действует только среди
|
||||
**активных** загрузок, а это условие на `download.state` — в индекс SQLite
|
||||
не выразить. PK `(infohash, download_id)` даёт и дедуп строк, и индекс для
|
||||
поиска по хешу.
|
||||
|
||||
### D4. Инвариант «одна активная загрузка на infohash» — двумя атомарными операциями store
|
||||
|
||||
`idempotency_key` и его CASE-восстановление удаляются; активность — чисто
|
||||
функция `state` (terminalStates).
|
||||
|
||||
**Критично:** сегодня финальный backstop инварианта — partial unique index
|
||||
по `idempotency_key`, и на него опираются **пять** путей записи (комментарии
|
||||
в коде прямо ссылаются на индекс): приём (`ingest`), adopt чужого торрента
|
||||
(`worker/discover.go`), ручной `Retry` (`worker.go`), воскрешение сверкой
|
||||
(`reconcile.go:reconcileOneRecovery`), `Relink` (`review.go`). Ни один из
|
||||
них сейчас не делает check+write в одной транзакции — гонку закрывал индекс.
|
||||
С удалением индекса **все пять** обязаны пройти через атомарные операции.
|
||||
|
||||
Вводим два guarded-метода `store`, каждый — одна write-транзакция
|
||||
(`BEGIN IMMEDIATE`; SQLite сериализует писателей, поэтому check-then-write
|
||||
внутри одной write-tx гонок не имеет):
|
||||
|
||||
- `CreateDownloadIfNoActive(d, hashes)` — проверка «нет активной загрузки с
|
||||
любым из хешей» (join `download_infohash` × `state`) → вставка `download`
|
||||
+ хешей; иначе возвращает существующую активную (семантика дедупа приёма),
|
||||
дописав ей недостающие хеши из вызова (второй хеш гибрида не теряется).
|
||||
Используют ingest и discover-adopt.
|
||||
- `ActivateIfNoOtherActive(id, toState, …)` — проверка «никакая ДРУГАЯ
|
||||
активная загрузка не владеет любым из хешей этой» (сама задача исключена
|
||||
из выборки — stuck-задача при retry активна и не должна маскировать
|
||||
чужого владельца) → переход состояния; иначе отказ. Используют Retry,
|
||||
recovery-воскрешение, Relink; отказ — ДО побочных эффектов (Retry
|
||||
активирует до повторного qbt.Add, при сбое Add откатывает состояние).
|
||||
- `AddInfohashes(id, hashes)` — дозапись хешей (раскрытие гибрида) под тем
|
||||
же гардом: хеш чужой активной задачи не дописывается (ErrInfohashTaken).
|
||||
|
||||
Механический бэкстоп вместо удалённого unique-индекса: `SetDownloadState`
|
||||
отклоняет переход терминал→активное (предикат в UPDATE) — оживление идёт
|
||||
только через `ActivateIfNoOtherActive`.
|
||||
|
||||
`FindActiveByInfohash`/`ExistsByInfohash` переезжают на join по
|
||||
`download_infohash` и остаются для чтения (не как гард).
|
||||
|
||||
### D5. Накопление хешей из поллинга
|
||||
|
||||
Magnet-парсер извлекает btih (v1) **и** btmh (v2) гибридной ссылки
|
||||
(`Info.Infohashes`, v1 первым) — приём записывает ВСЕ известные хеши,
|
||||
`kind` — по длине hex: 40 = v1, 64 = v2 (не хардкодить v1). Когда
|
||||
qBittorrent отдаёт торрент с заполненными `infohash_v1`/`infohash_v2`,
|
||||
поллинг дописывает недостающие строки через guarded `AddInfohashes`.
|
||||
Сборщик хешей торрента один — `torrentHashes`: поле `hash` qBittorrent
|
||||
берётся только при пустых v1/v2 (старый API), потому что у v2-only раздач
|
||||
это УСЕЧЁННЫЙ v2 (40 hex, по длине неотличим от v1) — его не храним, а
|
||||
SourceRef усыновления строится из полноразмерного хеша (btih/btmh).
|
||||
Сопоставление раздачи — по любому из хешей загрузки; live-карта воркера
|
||||
уже ключуется всеми формами хеша торрента, модель с ней совместима.
|
||||
|
||||
### D6. Миграция: одна Go-миграция goose
|
||||
|
||||
Первая Go-миграция в проекте (до сих пор — только SQL-файлы из embed).
|
||||
Механизм: goose поддерживает смешение — classic API (`goose.SetBaseFS` +
|
||||
`goose.Up`) подхватывает и зарегистрированные Go-миграции. Регистрация —
|
||||
`goose.AddMigrationContext` в `init()` пакета `store/migrations`, файл
|
||||
`0006_*.go`; пакет становится Go-пакетом и должен быть импортирован из
|
||||
`store.go` (иначе `init()` не выполнится). SQL не может генерить ULID —
|
||||
поэтому Go.
|
||||
|
||||
Вся миграция — в одной транзакции goose. Важно: `PRAGMA foreign_keys=OFF`
|
||||
внутри транзакции — тихий no-op в SQLite, а DSN включает FK на каждом
|
||||
соединении, поэтому **работаем с включёнными FK** и соблюдаем порядок:
|
||||
|
||||
1. Прочитать строки старых таблиц **в порядке старого `id`** (хронология).
|
||||
2. Сгенерить маппинг `old int id → ULID` для каждой таблицы:
|
||||
**timestamp-часть — из `created_at` строки** (UTC в БД), entropy — через
|
||||
`ulid.Monotonic`-reader. `created_at` имеет секундное разрешение и
|
||||
дубли — норма (батч `file_link`): monotonic-инкремент entropy при равном
|
||||
timestamp сохраняет относительный порядок старых id. Непарсибельный
|
||||
`created_at` → время миграции.
|
||||
3. Создать новые таблицы (`*_new`) **родители первыми**, дочерние — с
|
||||
`REFERENCES` на `*_new`-родителей; заливать данные тоже родители-первыми
|
||||
(FK включены — порядок обязателен). `download.infohash` разносится в
|
||||
`download_infohash_new` (lowercase, `kind` по длине hex);
|
||||
`idempotency_key` и `download.infohash` опускаются.
|
||||
4. `DROP` старых таблиц **дети первыми**, затем `ALTER TABLE … RENAME`
|
||||
(`*_new` → канонические имена; SQLite ≥ 3.25 переписывает `REFERENCES`
|
||||
в ссылающихся таблицах при переименовании), пересоздать индексы.
|
||||
5. Финальный `PRAGMA foreign_key_check` как самопроверка.
|
||||
|
||||
### D7. Границы: URL, ссылки, логи
|
||||
|
||||
- `httpapi`: парсинг `{id}` централизован в `pathID` — там `ident.Parse`
|
||||
вместо `strconv.ParseInt`; невалидный id → 404 без похода в БД. Вне
|
||||
`{id}`-роутов: `candidate_id` из формы ревью, сентинелы `downloadID > 0`
|
||||
в `errBody`/`userErr` (со string — `!= ""`). **BREAKING для REST JSON**:
|
||||
поле `id` в DTO меняет тип `number → string`.
|
||||
- Telegram: ссылки бота ведут на `/review/{id}` (не только `/download/`),
|
||||
callback-data содержит id (`parseCallback` через `strconv.ParseInt`,
|
||||
сентинел `id == 0`, `pending map[int64]int64`) — всё переводится на
|
||||
string/ULID. Старые сообщения: числовые URL отдадут 404, нажатие старой
|
||||
inline-кнопки должно получать понятный ответ «кнопка устарела», а не
|
||||
панику/тишину.
|
||||
- Логи: поле `<entity>_id` у каждой сущности (`download_id`,
|
||||
`recognition_id`, `batch_id`); scoped-логгер уже есть; grep по голому ULID
|
||||
— штатный способ корреляции наравне с jq.
|
||||
- Сортировки: списки сегодня сортируются `ORDER BY id DESC` (и
|
||||
`COALESCE(source_added_at, created_at), id`) — с ULID это остаётся
|
||||
корректным благодаря сортируемости и хронологическому бэкфиллу; менять
|
||||
запросы не требуется.
|
||||
|
||||
### D8. Экспозиция множества хешей наружу
|
||||
|
||||
`download.infohash` читают не только дедуп и поллинг: карточка загрузки и
|
||||
кнопка копирования (требование web-ui), REST DTO, live-лукап
|
||||
(`Live(d.Infohash)`), scoped-логгеры воркера, поиск в списке
|
||||
(`listWhere … infohash LIKE`). Решения:
|
||||
|
||||
- Модель `Download` дополняется срезом хешей, подгружаемым вместе с записью
|
||||
(или методом `store`); карточка и REST показывают **все** хеши загрузки
|
||||
(v1 и v2, каждый с копированием); REST-поле `infohash` заменяется на
|
||||
`infohashes` (список).
|
||||
- Live-лукап — по любому из хешей (live-карта воркера уже ключуется всеми
|
||||
формами хеша торрента).
|
||||
- Scoped-логгер кладёт в поле `infohash` первый известный хеш (для
|
||||
корреляции этого достаточно — id теперь главный ключ поиска по логам).
|
||||
- Поиск в списке — `EXISTS`-подзапрос по `download_infohash` вместо
|
||||
`LIKE` по удаляемому столбцу.
|
||||
|
||||
### D9. Конвенция
|
||||
|
||||
Новый `docs/conventions/database.md`: PK — TEXT ULID, генерится приложением
|
||||
через `internal/ident`; числовой AUTOINCREMENT не используем; у
|
||||
деталей/связей допустим естественный/составной ключ; канонический вид id —
|
||||
lowercase, нормализация на входных границах. Ссылки — из README конвенций и
|
||||
CLAUDE.md. ER-схема `docs/specs/database.md` обновляется в том же change.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Гонка дедупа без UNIQUE-гарда] → check-then-insert строго в одной
|
||||
write-транзакции (`BEGIN IMMEDIATE`), SQLite сериализует писателей.
|
||||
- [Ошибка миграции портит данные] → миграция в транзакции goose
|
||||
(SQLite умеет транзакционный DDL); перед деплоем — копия файла БД
|
||||
(штатный бэкап на umbar пока не автоматизирован — сделать руками).
|
||||
- [Старые URL в истории Telegram/закладках и старые inline-кнопки ломаются]
|
||||
→ принято: домашний сервис, история коротка; редиректов со старых числовых
|
||||
id не делаем; на устаревшую callback-data бот отвечает понятной ошибкой.
|
||||
- [`created_at` непарсибелен] → fallback на время миграции, порядок ULID
|
||||
внутри таблицы всё равно монотонен (entropy-инкремент).
|
||||
- [Коллизия ULID] → 80 бит энтропии на миллисекунду, единственный генератор
|
||||
в одном процессе — пренебрежимо.
|
||||
- [Первая Go-миграция усложняет store/migrations] → цена принята: паттерн
|
||||
понадобится и дальше (backfill-миграции), закладываем аккуратно.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Код + миграция в одном бинаре; goose прогоняет миграцию на старте, как
|
||||
обычно.
|
||||
2. Перед деплоем на umbar — ручная копия SQLite-файла (data-том).
|
||||
3. Откат = восстановить копию файла + прежний бинарь (совместимость схем
|
||||
вниз не поддерживаем).
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Нет блокирующих. Мелочь на реализацию: `hint`/`override`/
|
||||
`metadata_candidate` нигде не светятся наружу — их ULID нужны только для
|
||||
единообразия и логов, отдельных требований не несут.
|
||||
@@ -0,0 +1,69 @@
|
||||
## Why
|
||||
|
||||
Идентичность в домене сегодня держится на двух хрупких вещах: загрузка
|
||||
фактически идентифицируется инфохэшем (`idempotency_key`), хотя у одной
|
||||
логической загрузки хешей несколько (v1/v2/гибрид, перезалив — другой хеш),
|
||||
а первичные ключи всех таблиц — числовые автоинкременты, не уникальные между
|
||||
таблицами и неудобные для корреляции в логах. Это фундамент (шаг 1 черновика
|
||||
[logical-title-model](../../../docs/drafts/logical-title-model.md)) для
|
||||
«второго сезона», «докачивания» и истории переходов; менять PK дешевле
|
||||
сейчас, пока БД маленькая и ссылок на идентификатор немного.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **ULID (канонически lowercase) как TEXT PK всех сущностей**: `download`,
|
||||
`recognition`, `hint`, `override`, `metadata_candidate`, `file_link`.
|
||||
Генерация — в приложении (`oklog/ulid`, monotonic entropy). **BREAKING**:
|
||||
формат id меняется в URL (`/download/{id}`, `/review/{id}`), ссылках и
|
||||
callback-data Telegram-бота, логах; в REST JSON поле `id` меняет тип
|
||||
`number → string`, поле `infohash` заменяется списком `infohashes`.
|
||||
- **Новая таблица `download_infohash`** (`download_id`, `infohash`,
|
||||
`kind` v1|v2, составной PK `(infohash, download_id)` — один хеш легитимно
|
||||
принадлежит нескольким загрузкам во времени) — множество хешей одной
|
||||
загрузки; дедуп переезжает на проверку активности по этой таблице,
|
||||
столбцы `download.idempotency_key` и `download.infohash` удаляются.
|
||||
- **Поиск по любому из хешей** — при приёме (дедуп) и в поллинге qBittorrent.
|
||||
- `apply_batch_id` генерируется как ULID (столбец уже TEXT).
|
||||
- **Go-миграция goose**: backfill ULID существующим строкам с timestamp-частью
|
||||
из `created_at` (сортировка id сохраняет хронологию), переписывание FK,
|
||||
разнос текущего `infohash` в `download_infohash`.
|
||||
- **Конвенция `docs/conventions/database.md`**: PK — TEXT ULID, генерится
|
||||
приложением; числовой AUTOINCREMENT не используем; у деталей/связей
|
||||
допустим естественный ключ.
|
||||
- **Логи**: у каждой сущности поле `<entity>_id`; глобальная уникальность
|
||||
ULID делает grep по голому id штатным способом корреляции; обновить
|
||||
примеры в `docs/conventions/logging.md`.
|
||||
- ER-схема `docs/specs/database.md` обновляется в этом же change.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `identity`: как система идентифицирует сущности домена — ULID-ключи и их
|
||||
канонический вид (нормализация на входных границах), множество инфохэшей
|
||||
загрузки, инвариант «одна активная загрузка на infohash» (дедуп при приёме),
|
||||
сопоставление раздачи в поллинге по любому из хешей.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `state-reconciliation`: требование «терминализация восстанавливает
|
||||
`idempotency_key`» меняется — инвариант «одна активная задача на infohash»
|
||||
обеспечивается проверкой активности по `download_infohash`, отдельный
|
||||
снимаемый/восстанавливаемый ключ исчезает.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Код**: `store` (типы id `int64 → string`, все запросы, миграция),
|
||||
`ingest` (дедуп через `download_infohash`), `worker` (сопоставление в
|
||||
поллинге по множеству хешей), `httpapi`/веб-UI (парсинг и валидация ULID в
|
||||
`/download/{id}`, ссылки), Telegram-уведомления (ссылки на загрузку).
|
||||
- **Зависимости**: + `github.com/oklog/ulid/v2` (чистый Go, CGO не нужен).
|
||||
- **БД**: пересоздание всех шести таблиц (SQLite меняет PK только через
|
||||
rebuild) одной миграцией; первая Go-миграция в проекте — goose до сих пор
|
||||
использовался только с SQL-файлами, нужна регистрация Go-миграций.
|
||||
- **Документация**: новая `docs/conventions/database.md`, правки
|
||||
`docs/conventions/logging.md`, ER-схема `docs/specs/database.md`,
|
||||
ссылка на новую конвенцию из `CLAUDE.md`/README конвенций.
|
||||
- **Не меняется**: семантика дедупа (нашли активную загрузку по любому хешу →
|
||||
та же загрузка), явные `ORDER BY created_at` в списках, инварианты
|
||||
безопасности данных.
|
||||
@@ -0,0 +1,156 @@
|
||||
# identity — идентичность сущностей домена
|
||||
|
||||
Как система идентифицирует сущности: ULID-ключи и их канонический вид,
|
||||
множество инфохэшей загрузки, дедупликация приёма, корреляция в логах.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: ULID как первичный ключ сущностей
|
||||
|
||||
Каждая сущность домена SHALL иметь первичный ключ ULID — TEXT, 26 символов
|
||||
Crockford base32, генерируемый приложением в момент создания записи через
|
||||
единственную точку генерации (`internal/ident`). Сущности: `download`,
|
||||
`recognition`, `hint`, `override`, `metadata_candidate`, `file_link`.
|
||||
Канонический вид SHALL быть lowercase. Числовые AUTOINCREMENT-ключи в новых таблицах
|
||||
использоваться SHALL NOT. Идентификатор партии раскладки (`apply_batch_id`)
|
||||
SHALL генерироваться тем же способом.
|
||||
|
||||
#### Scenario: Создание загрузки
|
||||
|
||||
- **WHEN** принимается новая загрузка
|
||||
- **THEN** её `id` — валидный ULID в lowercase
|
||||
- **AND** `id` уникален глобально (не совпадает с id других сущностей)
|
||||
|
||||
#### Scenario: Хронологическая сортировка
|
||||
|
||||
- **GIVEN** две загрузки, созданные последовательно
|
||||
- **WHEN** записи сортируются по `id` лексикографически
|
||||
- **THEN** порядок совпадает с порядком создания
|
||||
|
||||
### Requirement: Нормализация и валидация id на входных границах
|
||||
|
||||
Внешние идентификаторы SHALL валидироваться как ULID и нормализоваться к
|
||||
lowercase до обращения к хранилищу — это касается всех входных границ:
|
||||
URL `/download/{id}`, параметры форм и команд. Синтаксически невалидный id SHALL обрабатываться как
|
||||
несуществующая сущность (404 для страниц), без обращения к БД.
|
||||
|
||||
#### Scenario: Uppercase-вариант id в URL
|
||||
|
||||
- **GIVEN** существующая загрузка с id `01jz…` (lowercase)
|
||||
- **WHEN** клиент открывает `/download/01JZ…` (uppercase)
|
||||
- **THEN** открывается страница той же загрузки
|
||||
|
||||
#### Scenario: Мусор вместо id
|
||||
|
||||
- **WHEN** клиент открывает `/download/abc!!!`
|
||||
- **THEN** ответ — 404, запрос к БД не выполняется
|
||||
|
||||
### Requirement: Множество инфохэшей загрузки
|
||||
|
||||
Загрузка SHALL иметь одну или более записей инфохэша (`download_infohash`:
|
||||
`infohash` lowercase hex, `kind` ∈ `v1`|`v2`). При приёме magnet-ссылки
|
||||
SHALL записываться ВСЕ известные из неё хеши — гибридный magnet несёт и
|
||||
btih (v1), и btmh (v2); `kind` определяется по длине hex (40 — `v1`, 64 —
|
||||
`v2`). Когда qBittorrent сообщает для раздачи оба хеша (`infohash_v1`,
|
||||
`infohash_v2`), система SHALL дописывать недостающие записи загрузке;
|
||||
усечённый хеш v2-only раздачи (поле `hash` qBittorrent, 40 hex от v2)
|
||||
записываться SHALL NOT. Сопоставление раздачи qBittorrent с загрузкой
|
||||
(поллинг, discover) SHALL выполняться по любому из известных хешей. Один и
|
||||
тот же infohash MAY принадлежать нескольким загрузкам во времени (повторный
|
||||
приём после терминального состояния), но активной из них MUST быть не более
|
||||
одной.
|
||||
|
||||
#### Scenario: Гибридный торрент раскрывает оба хеша
|
||||
|
||||
- **GIVEN** загрузка принята по magnet с v1-хешем
|
||||
- **WHEN** qBittorrent отдаёт раздачу с заполненными `infohash_v1` и
|
||||
`infohash_v2`
|
||||
- **THEN** у загрузки появляются обе записи (`kind` = `v1` и `v2`)
|
||||
|
||||
#### Scenario: Сопоставление по v2-хешу
|
||||
|
||||
- **GIVEN** загрузка с записями v1- и v2-хешей
|
||||
- **WHEN** поллинг находит раздачу, совпавшую только по v2-хешу
|
||||
- **THEN** раздача сопоставляется с этой загрузкой
|
||||
|
||||
### Requirement: Дедупликация приёма по любому из хешей
|
||||
|
||||
При приёме система SHALL искать **активную** (нетерминальную) загрузку по
|
||||
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
|
||||
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
|
||||
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
|
||||
более одной активной загрузки на infohash». Отдельного снимаемого/
|
||||
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
|
||||
выводится только из `state`.
|
||||
|
||||
#### Scenario: Повторный приём при активной загрузке
|
||||
|
||||
- **GIVEN** активная загрузка с infohash `h`
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||
|
||||
#### Scenario: Повторный приём после завершения
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`)
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
|
||||
|
||||
### Requirement: Атомарность возврата загрузки в активное состояние
|
||||
|
||||
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
|
||||
возвращающем загрузку из терминального состояния в активное (ручной retry,
|
||||
воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её
|
||||
(приём, adopt чужой раздачи), что никакая другая активная загрузка не
|
||||
владеет любым из хешей этой, и при владении SHALL отказывать в переходе,
|
||||
сохраняя инвариант «не более одной активной загрузки на infohash».
|
||||
Отказ SHALL происходить до побочных эффектов во внешних системах
|
||||
(повторного добавления торрента в qBittorrent).
|
||||
|
||||
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие
|
||||
гибридного торрента): хеш, которым владеет другая активная загрузка,
|
||||
дописан быть SHALL NOT. Прямой перевод терминальной загрузки в активное
|
||||
состояние в обход этой проверки SHALL отклоняться хранилищем (механический
|
||||
бэкстоп вместо удалённого unique-индекса).
|
||||
|
||||
#### Scenario: Retry при занятом хеше
|
||||
|
||||
- **GIVEN** загрузка #1 в `failed` с хешем `h`, и другая активная загрузка
|
||||
#2 с тем же `h`
|
||||
- **WHEN** пользователь вызывает retry для #1
|
||||
- **THEN** переход отклоняется с пояснением, #1 остаётся в `failed`
|
||||
- **AND** активной по `h` остаётся #2
|
||||
|
||||
### Requirement: Корреляция сущностей в логах
|
||||
|
||||
Записи журнала, относящиеся к сущности, SHALL содержать её id в атрибуте
|
||||
`<entity>_id` (`download_id`, `recognition_id`, `batch_id`, …); работа в
|
||||
контексте загрузки ведётся через scoped-логгер с `download_id`. Благодаря
|
||||
глобальной уникальности ULID поиск по значению id (grep/jq) SHALL находить
|
||||
все записи журнала, относящиеся к сущности, независимо от имени поля.
|
||||
|
||||
#### Scenario: Путь загрузки по логам
|
||||
|
||||
- **GIVEN** загрузка прошла приём, распознавание и раскладку
|
||||
- **WHEN** журнал фильтруется по значению её `id`
|
||||
- **THEN** находятся записи всех этапов (ingest, recognition, file-layout)
|
||||
|
||||
### Requirement: Миграция существующих записей
|
||||
|
||||
Существующие записи SHALL получить ULID-идентификаторы одной миграцией с
|
||||
сохранением всех связей (FK) и хронологии: timestamp-часть ULID SHALL
|
||||
браться из `created_at` записи, чтобы лексикографический порядок новых id
|
||||
соответствовал историческому порядку создания. Существующий
|
||||
`download.infohash` SHALL быть перенесён в `download_infohash`
|
||||
(нормализация к lowercase, `kind` по длине hex: 40 — `v1`, 64 — `v2`);
|
||||
столбцы `download.infohash` и `download.idempotency_key` SHALL быть удалены.
|
||||
|
||||
#### Scenario: Связи и порядок после миграции
|
||||
|
||||
- **GIVEN** БД с загрузками, распознаваниями и файловыми ссылками на
|
||||
числовых id
|
||||
- **WHEN** миграция выполнена
|
||||
- **THEN** все FK-связи сохранены (распознавания/ссылки указывают на те же
|
||||
загрузки)
|
||||
- **AND** порядок загрузок по `id` совпадает с порядком по `created_at`
|
||||
- **AND** каждый прежний `infohash` представлен записью в
|
||||
`download_infohash`
|
||||
@@ -0,0 +1,113 @@
|
||||
# state-reconciliation — дельта для ulid-identity
|
||||
|
||||
Механика идемпотентности меняется: снимаемый/восстанавливаемый
|
||||
`idempotency_key` исчезает, инвариант «не более одной активной задачи на
|
||||
infohash» обеспечивается проверкой активности по `download_infohash`
|
||||
(см. capability `identity`).
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Периодическая сверка состояния с реальностью
|
||||
|
||||
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
|
||||
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
|
||||
выводить состояние задачи из двух независимых признаков: присутствия
|
||||
**источника** (раздача, совпавшая с **любым из известных хешей** загрузки в
|
||||
`download_infohash`, в выдаче qBittorrent) и присутствия **цели** (см.
|
||||
требование о владении целевым путём: существуют все ссылки последнего батча
|
||||
со статусом раскладки, всё ещё принадлежащие этой загрузке).
|
||||
|
||||
Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`,
|
||||
`target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT —
|
||||
оно терминально. Активные (`downloading`/`recognizing`/`review`/`deferred`/
|
||||
`linking`) и пользовательски-терминальные (`reverted`/`cancelled`) состояния
|
||||
сверка по матрице трогать SHALL NOT.
|
||||
|
||||
**Восстановимые** `failed`/`stuck` (с `error_code` `magnet_timeout` или
|
||||
`stalled` — задержки, вызванные нашей нетерпеливостью, а не реальной ошибкой)
|
||||
сверка SHALL рассматривать отдельно — на предмет оживления источника (см.
|
||||
требование о восстановлении зависшей загрузки), не по матрице «источник ×
|
||||
цель». Прочие `failed` (например `qbit_error`) сверка трогать SHALL NOT.
|
||||
|
||||
Состояние SHALL переписываться только при его изменении (без записи и логов,
|
||||
когда выведенное состояние совпадает с текущим).
|
||||
|
||||
#### Scenario: Источник и цель на месте — состояние не меняется
|
||||
|
||||
- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её
|
||||
разложенные хардлинки существуют
|
||||
- **THEN** задача остаётся в `done`
|
||||
- **AND** запись состояния и лог перехода не выполняются
|
||||
|
||||
#### Scenario: Частичная пропажа цели считается отсутствием
|
||||
|
||||
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
|
||||
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
|
||||
|
||||
#### Scenario: Задача в deleted сверкой не переоценивается
|
||||
|
||||
- **WHEN** задача находится в `deleted`
|
||||
- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её
|
||||
бывшему пути появился файл другой загрузки
|
||||
|
||||
#### Scenario: Провал по ошибке qBittorrent восстановлению не подлежит
|
||||
|
||||
- **WHEN** задача в `failed` с `error_code` `qbit_error`
|
||||
- **THEN** сверка её не рассматривает и состояние не меняет
|
||||
|
||||
### Requirement: Восстановление зависшей загрузки при оживлении источника
|
||||
|
||||
Система SHALL возвращать в активный поток задачу, упавшую из-за нашей
|
||||
нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её
|
||||
источник в qBittorrent жив и продвинулся: переход выводится из текущего
|
||||
состояния торрента так же, как при штатной сверке загрузки
|
||||
(`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… →
|
||||
`downloading`). Восстановление SHALL опираться на фактическое состояние
|
||||
торрента в qBittorrent, а не на время с момента создания записи.
|
||||
|
||||
После возврата в любое нетерминальное состояние (`downloading` или
|
||||
`completed`) повторный приём того же infohash SHALL снова дедуплицироваться
|
||||
на эту задачу: активность задачи выводится только из её `state`, отдельный
|
||||
восстанавливаемый ключ идемпотентности отсутствует. Если за время простоя в
|
||||
`failed`/`stuck` тем же infohash (любым из хешей задачи) уже завладела
|
||||
другая активная задача (новый приём, пока эта лежала упавшей), система
|
||||
SHALL NOT воскрешать упавшую задачу и SHALL оставить её в `failed`/`stuck`,
|
||||
сохраняя инвариант «не более одной активной задачи на infohash».
|
||||
|
||||
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
|
||||
рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом
|
||||
прогрессирует в пределах страховочного таймаута, задача в `failed`/`stuck`
|
||||
из-за него оказаться SHALL NOT (см. требование о терпеливости к долгим
|
||||
метаданным в `docs/specs/workflow.md`).
|
||||
|
||||
#### Scenario: Метаданные пришли после magnet_timeout
|
||||
|
||||
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
|
||||
в qBittorrent уже получил метаданные и качается (`downloading`)
|
||||
- **WHEN** срабатывает фоновая сверка
|
||||
- **THEN** задача возвращается в `downloading`
|
||||
- **AND** повторный приём того же infohash снова дедуплицируется на неё
|
||||
|
||||
#### Scenario: Торрент уже завершился, пока задача была в failed
|
||||
|
||||
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
|
||||
в qBittorrent уже готов к раскладке (`uploading`/`stalledUP`)
|
||||
- **WHEN** срабатывает фоновая сверка
|
||||
- **THEN** задача переходит в `completed` и продолжает обычный поток
|
||||
(распознавание/раскладка)
|
||||
|
||||
#### Scenario: Источник так и не ожил — состояние не меняется
|
||||
|
||||
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
|
||||
всё ещё висит в `metaDL` без метаданных (или отсутствует в qBittorrent)
|
||||
- **WHEN** срабатывает фоновая сверка
|
||||
- **THEN** задача остаётся в `failed`
|
||||
|
||||
#### Scenario: infohash уже занят другой активной задачей
|
||||
|
||||
- **GIVEN** задача #1 в `failed`/`magnet_timeout`, а тем же infohash уже
|
||||
владеет другая активная задача #2 (приём повторили, пока #1 лежала упавшей)
|
||||
- **WHEN** источник ожил (торрент получил метаданные или готов) и сверка
|
||||
пытается воскресить #1
|
||||
- **THEN** #1 остаётся в `failed` (восстановление не выполняется)
|
||||
- **AND** активной по этому infohash остаётся #2
|
||||
@@ -0,0 +1,81 @@
|
||||
## 1. Фундамент: пакет ident
|
||||
|
||||
- [x] 1.1 Добавить зависимость `github.com/oklog/ulid/v2`; пакет
|
||||
`internal/ident`: `NewID()` (lowercase, monotonic entropy,
|
||||
потокобезопасно), `NewIDAt(t time.Time)` (для миграции/бэкфилла),
|
||||
`Parse(s)` (нормализация регистра + валидация); тесты
|
||||
(lowercase, сортируемость, отказ на мусоре)
|
||||
|
||||
## 2. Схема и миграция
|
||||
|
||||
- [x] 2.1 Механизм Go-миграций goose в `internal/store/migrations`
|
||||
(регистрация через `goose.AddMigrationContext`, совместный прогон с
|
||||
embed SQL-миграциями)
|
||||
- [x] 2.2 Миграция 0006: новые таблицы с TEXT ULID PK (все шесть), перенос
|
||||
данных с маппингом `int → ULID` (timestamp из `created_at`, fallback —
|
||||
время миграции), перенос `download.infohash` → `download_infohash`
|
||||
(lowercase, `kind` по длине hex), удаление `download.infohash` и
|
||||
`download.idempotency_key`, пересоздание индексов
|
||||
- [x] 2.3 Тест миграции на фикстурной БД: FK-связи сохранены, порядок по
|
||||
`id` = порядок по `created_at`, хеши разнесены, `idempotency_key`
|
||||
отсутствует
|
||||
|
||||
## 3. Store
|
||||
|
||||
- [x] 3.1 Типы id `int64 → string` во всех структурах и методах `store`
|
||||
(download, recognition, hint, override, metadata_candidate,
|
||||
file_link, list); генерация ULID через `ident.NewID()` во ВСЕХ
|
||||
Create-методах (вместо `LastInsertId`)
|
||||
- [x] 3.2 Guarded-методы инварианта (design D4):
|
||||
`CreateDownloadIfNoActive` и `ActivateIfNoOtherActive`, каждый — одна
|
||||
write-транзакция; `FindActiveByInfohash`/`ExistsByInfohash` join'ом
|
||||
по любому хешу (для чтения); убрать CASE-восстановление
|
||||
`idempotency_key` из `SetDownloadState`
|
||||
- [x] 3.3 Методы хешей: добавить недостающие хеши загрузке
|
||||
(INSERT OR IGNORE), получать хеши вместе с Download (срез в модели)
|
||||
- [x] 3.4 Поиск в списке (`listWhere`): `EXISTS`-подзапрос по
|
||||
`download_infohash` вместо `LIKE` по удаляемому `download.infohash`
|
||||
|
||||
## 4. Ядро и воркер
|
||||
|
||||
- [x] 4.1 Приём (`ingest`) и discover-adopt — через
|
||||
`CreateDownloadIfNoActive`; хеш из magnet (btih ИЛИ btmh, `kind` по
|
||||
длине hex) пишется в той же транзакции; сигнатуры
|
||||
`Result.DownloadID`, `notifyFailed`, `Notifier.Notify`,
|
||||
`failNotified` — на string
|
||||
- [x] 4.2 Поллинг/сверка: сопоставление раздачи по любому из хешей загрузки
|
||||
(Poll, desync, recovery, preflight); дописывание недостающих v1/v2,
|
||||
когда qBittorrent отдаёт оба; scoped-логгеры — первый известный хеш
|
||||
- [x] 4.3 Retry, recovery-воскрешение и Relink — через
|
||||
`ActivateIfNoOtherActive` (сейчас гонку закрывал unique-индекс —
|
||||
см. design D4); понятная ошибка при занятом хеше
|
||||
- [x] 4.4 `apply_batch_id` генерировать через `ident.NewID()`
|
||||
|
||||
## 5. Внешние границы
|
||||
|
||||
- [x] 5.1 `httpapi`: `ident.Parse` в `pathID` (невалидный → 404 без похода
|
||||
в БД) и для `candidate_id` из формы ревью; сентинелы
|
||||
`downloadID > 0 → != ""`; REST DTO: `id` string, `infohash` →
|
||||
список `infohashes`; карточка показывает все хеши с копированием;
|
||||
live-лукап по любому хешу; проверить шаблоны и ссылки
|
||||
- [x] 5.2 Telegram (`tgbot`): `parseCallback` и callback-data на string-id,
|
||||
`pending map[int64]int64 → map[int64]string`, сентинел `id == 0 →
|
||||
== ""`, ссылки `/review/{id}`; понятный ответ на устаревшую
|
||||
callback-data со старым числовым id
|
||||
|
||||
## 6. Логи и документация
|
||||
|
||||
- [x] 6.1 Атрибуты `<entity>_id` в логах: `recognition_id` у попыток
|
||||
распознавания, `batch_id` у раскладки; сверить с scoped-логгером
|
||||
- [x] 6.2 `docs/conventions/logging.md`: примеры id в формате ULID, grep по
|
||||
голому id как штатная корреляция
|
||||
- [x] 6.3 Новая `docs/conventions/database.md` (TEXT ULID PK, без
|
||||
AUTOINCREMENT, естественные ключи у деталей, lowercase + нормализация);
|
||||
ссылки из `docs/conventions/README.md` и `CLAUDE.md`
|
||||
- [x] 6.4 ER-схема `docs/specs/database.md`: ULID PK, `download_infohash`,
|
||||
удалённые столбцы
|
||||
|
||||
## 7. Проверка
|
||||
|
||||
- [x] 7.1 `task test` и `task lint` зелёные; ручной прогон: приём magnet →
|
||||
дедуп повторного приёма → страница `/download/{id}` с ULID в URL
|
||||
@@ -0,0 +1,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 периодически (на тике поллинга) сверять задачи, для которых
|
||||
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
|
||||
выводить состояние задачи из двух независимых признаков: присутствия
|
||||
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и
|
||||
присутствия **цели** (см. требование о владении целевым путём: существуют все
|
||||
ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой
|
||||
загрузке).
|
||||
**источника** (раздача, совпавшая с **любым из известных хешей** загрузки в
|
||||
`download_infohash`, в выдаче qBittorrent) и присутствия **цели** (см.
|
||||
требование о владении целевым путём: существуют все ссылки последнего батча
|
||||
со статусом раскладки, всё ещё принадлежащие этой загрузке).
|
||||
|
||||
Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`,
|
||||
`target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT —
|
||||
@@ -70,14 +70,14 @@ qBittorrent. Capability описывает периодическую и при
|
||||
`downloading`). Восстановление SHALL опираться на фактическое состояние
|
||||
торрента в qBittorrent, а не на время с момента создания записи.
|
||||
|
||||
При возврате в любое нетерминальное состояние (`downloading` или
|
||||
`completed`) система SHALL восстанавливать идемпотентность задачи
|
||||
(`idempotency_key`), чтобы повторный приём того же infohash снова
|
||||
дедуплицировался на эту задачу. Если за время простоя в `failed`/`stuck` тем
|
||||
же infohash уже завладела другая активная задача (ключ снимается при падении и
|
||||
мог быть перехвачен новым приёмом), система SHALL NOT воскрешать упавшую
|
||||
задачу и SHALL оставить её в `failed`/`stuck`, сохраняя инвариант «не более
|
||||
одной активной задачи на infohash».
|
||||
После возврата в любое нетерминальное состояние (`downloading` или
|
||||
`completed`) повторный приём того же infohash SHALL снова дедуплицироваться
|
||||
на эту задачу: активность задачи выводится только из её `state`, отдельный
|
||||
восстанавливаемый ключ идемпотентности отсутствует. Если за время простоя в
|
||||
`failed`/`stuck` тем же infohash (любым из хешей задачи) уже завладела
|
||||
другая активная задача (новый приём, пока эта лежала упавшей), система
|
||||
SHALL NOT воскрешать упавшую задачу и SHALL оставить её в `failed`/`stuck`,
|
||||
сохраняя инвариант «не более одной активной задачи на infohash».
|
||||
|
||||
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
|
||||
рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом
|
||||
@@ -91,7 +91,7 @@ qBittorrent. Capability описывает периодическую и при
|
||||
в qBittorrent уже получил метаданные и качается (`downloading`)
|
||||
- **WHEN** срабатывает фоновая сверка
|
||||
- **THEN** задача возвращается в `downloading`
|
||||
- **AND** её `idempotency_key` восстанавливается
|
||||
- **AND** повторный приём того же infohash снова дедуплицируется на неё
|
||||
|
||||
#### Scenario: Торрент уже завершился, пока задача была в failed
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
<div class="section-head"><h2>Информация о торренте</h2><span class="hint">сырой источник и infohash</span></div>
|
||||
<dl class="kv">
|
||||
<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>
|
||||
{{if .SourceFull}}
|
||||
<div class="src-block">
|
||||
|
||||
Reference in New Issue
Block a user