diff --git a/CLAUDE.md b/CLAUDE.md index b6a2a39..3ccd518 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index 90d012a..4fff950 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -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) diff --git a/docs/conventions/README.md b/docs/conventions/README.md index 55ef010..c83c64a 100644 --- a/docs/conventions/README.md +++ b/docs/conventions/README.md @@ -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 + нормализация на границах, + естественные ключи у деталей. diff --git a/docs/conventions/database.md b/docs/conventions/database.md new file mode 100644 index 0000000..8d7aecd --- /dev/null +++ b/docs/conventions/database.md @@ -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. diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md index 988e1e6..6d45a1b 100644 --- a/docs/conventions/logging.md +++ b/docs/conventions/logging.md @@ -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 в поле `_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`. ## Ошибки diff --git a/docs/specs/database.md b/docs/specs/database.md index 6d8832d..84f53d1 100644 --- a/docs/specs/database.md +++ b/docs/specs/database.md @@ -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`. diff --git a/go.mod b/go.mod index 5ec201f..80dacb7 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 8963528..4b2b8c6 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index c0b8436..452ce45 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -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) diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 6271916..5316198 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -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())) } diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index a77fd1a..9188311 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -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) } } diff --git a/internal/httpapi/live.go b/internal/httpapi/live.go index 2298cf5..07e2b23 100644 --- a/internal/httpapi/live.go +++ b/internal/httpapi/live.go @@ -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 diff --git a/internal/httpapi/live_test.go b/internal/httpapi/live_test.go index e11ed34..08c79c0 100644 --- a/internal/httpapi/live_test.go +++ b/internal/httpapi/live_test.go @@ -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) + } } diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go index f36287c..ae9eb03 100644 --- a/internal/httpapi/render_test.go +++ b/internal/httpapi/render_test.go @@ -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) } } diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index f202d8b..b994bbd 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -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) } diff --git a/internal/ident/ident.go b/internal/ident/ident.go new file mode 100644 index 0000000..f5dc000 --- /dev/null +++ b/internal/ident/ident.go @@ -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 +} diff --git a/internal/ident/ident_test.go b/internal/ident/ident_test.go new file mode 100644 index 0000000..c5b63e5 --- /dev/null +++ b/internal/ident/ident_test.go @@ -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) + } + } +} diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index d656746..8bc73b3 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -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, + } +} diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index f7ed784..030d9c2 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -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("уведомление о падении приёма не пришло") diff --git a/internal/magnet/magnet.go b/internal/magnet/magnet.go index e845e7e..5139bba 100644 --- a/internal/magnet/magnet.go +++ b/internal/magnet/magnet.go @@ -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 diff --git a/internal/magnet/magnet_test.go b/internal/magnet/magnet_test.go index d0e270b..5e10e88 100644 --- a/internal/magnet/magnet_test.go +++ b/internal/magnet/magnet_test.go @@ -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 diff --git a/internal/store/download.go b/internal/store/download.go index 1712a8c..8957eac 100644 --- a/internal/store/download.go +++ b/internal/store/download.go @@ -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 == "" { diff --git a/internal/store/download_test.go b/internal/store/download_test.go index 336099f..9985c72 100644 --- a/internal/store/download_test.go +++ b/internal/store/download_test.go @@ -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 { diff --git a/internal/store/errors.go b/internal/store/errors.go index 143c719..a153296 100644 --- a/internal/store/errors.go +++ b/internal/store/errors.go @@ -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") diff --git a/internal/store/list.go b/internal/store/list.go index 8f00742..e378666 100644 --- a/internal/store/list.go +++ b/internal/store/list.go @@ -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 } diff --git a/internal/store/list_test.go b/internal/store/list_test.go index 55a7a2d..f93fb69 100644 --- a/internal/store/list_test.go +++ b/internal/store/list_test.go @@ -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) diff --git a/internal/store/migration_test.go b/internal/store/migration_test.go new file mode 100644 index 0000000..259128d --- /dev/null +++ b/internal/store/migration_test.go @@ -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) + } + } +} diff --git a/internal/store/migrations/0006_ulid_identity.go b/internal/store/migrations/0006_ulid_identity.go new file mode 100644 index 0000000..921931a --- /dev/null +++ b/internal/store/migrations/0006_ulid_identity.go @@ -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() +} diff --git a/internal/store/recognition.go b/internal/store/recognition.go index 2b6d4b2..c6869e6 100644 --- a/internal/store/recognition.go +++ b/internal/store/recognition.go @@ -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) diff --git a/internal/store/recognition_test.go b/internal/store/recognition_test.go index a5642ba..aaf4e39 100644 --- a/internal/store/recognition_test.go +++ b/internal/store/recognition_test.go @@ -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) } diff --git a/internal/store/store.go b/internal/store/store.go index e837494..98d48bb 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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) diff --git a/internal/tgbot/bot.go b/internal/tgbot/bot.go index 53acfee..3dc8f70 100644 --- a/internal/tgbot/bot.go +++ b/internal/tgbot/bot.go @@ -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] diff --git a/internal/tgbot/bot_test.go b/internal/tgbot/bot_test.go index 1c4ad06..cc279eb 100644 --- a/internal/tgbot/bot_test.go +++ b/internal/tgbot/bot_test.go @@ -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) } } diff --git a/internal/tgbot/render.go b/internal/tgbot/render.go index 8ac5bf9..0506190 100644 --- a/internal/tgbot/render.go +++ b/internal/tgbot/render.go @@ -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) } diff --git a/internal/worker/discover.go b/internal/worker/discover.go index ac47fe3..9acb037 100644 --- a/internal/worker/discover.go +++ b/internal/worker/discover.go @@ -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 } diff --git a/internal/worker/discover_test.go b/internal/worker/discover_test.go index 6082195..dbd4e14 100644 --- a/internal/worker/discover_test.go +++ b/internal/worker/discover_test.go @@ -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) } } diff --git a/internal/worker/live_test.go b/internal/worker/live_test.go index 8f60a54..e6b7973 100644 --- a/internal/worker/live_test.go +++ b/internal/worker/live_test.go @@ -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) diff --git a/internal/worker/reconcile.go b/internal/worker/reconcile.go index fc1c7c0..acc7a0a 100644 --- a/internal/worker/reconcile.go +++ b/internal/worker/reconcile.go @@ -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) } diff --git a/internal/worker/reconcile_test.go b/internal/worker/reconcile_test.go index 1b1a63e..8dea5ee 100644 --- a/internal/worker/reconcile_test.go +++ b/internal/worker/reconcile_test.go @@ -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) } } diff --git a/internal/worker/recovery_test.go b/internal/worker/recovery_test.go index cebb193..d808996 100644 --- a/internal/worker/recovery_test.go +++ b/internal/worker/recovery_test.go @@ -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) } } diff --git a/internal/worker/review.go b/internal/worker/review.go index 0a45876..02b8f98 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -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 } } diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index 3e12325..f65694a 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -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: ""}, }) diff --git a/internal/worker/worker.go b/internal/worker/worker.go index c825b04..6a53c19 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -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 } diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index 15e0931..00186b8 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -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) задачи должен отклоняться") } } diff --git a/openspec/changes/archive/2026-07-02-ulid-identity/.openspec.yaml b/openspec/changes/archive/2026-07-02-ulid-identity/.openspec.yaml new file mode 100644 index 0000000..8e26fbe --- /dev/null +++ b/openspec/changes/archive/2026-07-02-ulid-identity/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-02 diff --git a/openspec/changes/archive/2026-07-02-ulid-identity/design.md b/openspec/changes/archive/2026-07-02-ulid-identity/design.md new file mode 100644 index 0000000..1187f65 --- /dev/null +++ b/openspec/changes/archive/2026-07-02-ulid-identity/design.md @@ -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-кнопки должно получать понятный ответ «кнопка устарела», а не + панику/тишину. +- Логи: поле `_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 нужны только для + единообразия и логов, отдельных требований не несут. diff --git a/openspec/changes/archive/2026-07-02-ulid-identity/proposal.md b/openspec/changes/archive/2026-07-02-ulid-identity/proposal.md new file mode 100644 index 0000000..d36323f --- /dev/null +++ b/openspec/changes/archive/2026-07-02-ulid-identity/proposal.md @@ -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 не используем; у деталей/связей + допустим естественный ключ. +- **Логи**: у каждой сущности поле `_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` в списках, инварианты + безопасности данных. diff --git a/openspec/changes/archive/2026-07-02-ulid-identity/specs/identity/spec.md b/openspec/changes/archive/2026-07-02-ulid-identity/specs/identity/spec.md new file mode 100644 index 0000000..fd3b87e --- /dev/null +++ b/openspec/changes/archive/2026-07-02-ulid-identity/specs/identity/spec.md @@ -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 в атрибуте +`_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` diff --git a/openspec/changes/archive/2026-07-02-ulid-identity/specs/state-reconciliation/spec.md b/openspec/changes/archive/2026-07-02-ulid-identity/specs/state-reconciliation/spec.md new file mode 100644 index 0000000..19d2051 --- /dev/null +++ b/openspec/changes/archive/2026-07-02-ulid-identity/specs/state-reconciliation/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-07-02-ulid-identity/tasks.md b/openspec/changes/archive/2026-07-02-ulid-identity/tasks.md new file mode 100644 index 0000000..db377ee --- /dev/null +++ b/openspec/changes/archive/2026-07-02-ulid-identity/tasks.md @@ -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 Атрибуты `_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 diff --git a/openspec/specs/identity/spec.md b/openspec/specs/identity/spec.md new file mode 100644 index 0000000..eea2795 --- /dev/null +++ b/openspec/specs/identity/spec.md @@ -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 в атрибуте +`_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` diff --git a/openspec/specs/state-reconciliation/spec.md b/openspec/specs/state-reconciliation/spec.md index 244ea05..5ef6eb9 100644 --- a/openspec/specs/state-reconciliation/spec.md +++ b/openspec/specs/state-reconciliation/spec.md @@ -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 diff --git a/web/templates/download.html b/web/templates/download.html index b28eaf7..0482e16 100644 --- a/web/templates/download.html +++ b/web/templates/download.html @@ -94,7 +94,7 @@

Информация о торренте

сырой источник и infohash
Тип источника
{{.SourceType}}
- {{if .Infohash}}
infohash
{{.Infohash}}
{{end}} + {{range .Infohashes}}
infohash
{{.}}
{{end}}
{{if .SourceFull}}