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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
av
2026-07-02 21:25:00 +03:00
co-authored by Claude Fable 5
parent b808ceff25
commit 37f2f6481a
53 changed files with 3640 additions and 1035 deletions
+6 -3
View File
@@ -124,9 +124,12 @@ Module path — `git.vakhrushev.me/av/jellybit`. Go 1.26, `CGO_ENABLED=0`.
файл (`config.toml` не коммитится, `0600`), не в env; валидация на старте:
[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.
+1 -1
View File
@@ -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)
+3
View File
@@ -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 + нормализация на границах,
естественные ключи у деталей.
+47
View File
@@ -0,0 +1,47 @@
# Конвенция: база данных и идентификаторы
Как мы устраиваем таблицы и ключи в SQLite. Актуальная схема —
[../specs/database.md](../specs/database.md); обоснование выбора ULID —
`openspec/changes/ulid-identity/design.md` (после архивации — в истории git).
## Первичные ключи — ULID, не автоинкремент
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи. `INTEGER PRIMARY KEY
AUTOINCREMENT` в новых таблицах не используем.
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
компактен и удобен в URL/логах (без дефисов — grep и двойной клик берут id
целиком), глобально уникален across таблиц — поиск по голому id находит
все записи сущности в логах.
- **Единственная точка генерации и разбора — `internal/ident`**:
`ident.NewID()` при создании (в Create-методах `store`), `ident.Parse()`
на входных границах. Никаких самодельных генераторов.
## Канонический вид — lowercase
- Генерим и храним id в **нижнем регистре**. Сравнение строк в SQLite
побайтовое, поэтому любой внешний id (URL, форма, callback-data)
ОБЯЗАТЕЛЬНО проходит `ident.Parse` до запроса к БД — он валидирует формат
и нормализует регистр (base32 ULID case-insensitive при декодировании).
- Синтаксически невалидный id трактуем как несуществующую сущность (404),
без похода в БД.
## Естественные и составные ключи — для деталей
- У таблиц-деталей/связей допустим естественный или составной ключ вместо
ULID, когда он есть по природе данных: `download_infohash` — PK
`(infohash, download_id)`, `override``UNIQUE(download_id, field)`.
Отдельный ULID там — мёртвый вес.
- Прочие генерируемые идентификаторы (например, `apply_batch_id`) — тоже
через `ident.NewID()`: единый формат, сортируемость, корреляция в логах.
## Прочее
- Enum-поля (`state`, `kind`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код (`internal/store`).
- Временные метки — `TEXT DEFAULT (datetime('now'))` (UTC), формат
`store.ParseTime`/`FormatTime`.
- Миграции — goose (`internal/store/migrations`): SQL-файлы для DDL;
Go-миграции (`goose.AddMigrationContext`) — когда нужен код (генерация
id, backfill). При изменении структуры обновляем ER-схему
[../specs/database.md](../specs/database.md) в том же change.
+13 -8
View File
@@ -18,7 +18,7 @@ OpenSpec-спеках (`### Requirement` с `SHALL`).
фильтрацию и агрегацию через `jq`/DuckDB без регулярок.
```json
{"time":"2026-06-28T11:23:45.123456Z","level":"INFO","msg":"download accepted","capability":"ingest","download_id":"a1b2","infohash":"…","media_type":"movie","title":"Дюна: Часть вторая"}
{"time":"2026-06-28T11:23:45.123456Z","level":"INFO","msg":"download accepted","capability":"ingest","download_id":"01jz2k7f8q9r3s4t5v6w7x8y9z","infohash":"…","media_type":"movie","title":"Дюна: Часть вторая"}
```
## Сообщение
@@ -100,14 +100,16 @@ log.Info(fmt.Sprintf("download %s accepted as movie", id))
Если когда-нибудь поедем в несколько инстансов, добавим `service.version`
одной строкой при старте.
## Корреляция по download_id
## Корреляция по id сущности
Отдельный случайный `trace_id` не заводим — у загрузки уже есть стабильный
осмысленный ключ: `download_id` (и `infohash`), он лежит в SQLite.
Отдельный случайный `trace_id` не заводим — у сущностей уже есть стабильные
осмысленные ключи: ULID-идентификаторы (`download_id`, `recognition_id`,
`batch_id`, см. [database.md](database.md)), они лежат в SQLite.
- Заводим scoped-логгер на загрузку и протаскиваем его через
`context.Context` сквозь асинхронные стадии (приём → скачивание →
распознавание → раскладка), чтобы ключ дописывался на каждую запись сам:
- Каждая запись, относящаяся к сущности, несёт её id в поле `<entity>_id`.
Для загрузки — scoped-логгер, протаскиваемый через `context.Context`
сквозь асинхронные стадии (приём → скачивание → распознавание →
раскладка), чтобы ключ дописывался на каждую запись сам:
```go
log := log.With("download_id", id, "infohash", ih)
@@ -115,7 +117,10 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
```
- Все записи одной загрузки собираются одним фильтром:
`jq 'select(.download_id=="a1b2")' app.jsonl`.
`jq 'select(.download_id=="01jz2k7f8q9r3s4t5v6w7x8y9z")' app.jsonl`.
- ULID глобально уникален across сущностей, поэтому штатно работает и
простой grep по голому id — он находит все упоминания сущности независимо
от имени поля: `grep 01jz2k7f8q9r3s4t5v6w7x8y9z app.jsonl`.
## Ошибки
+37 -21
View File
@@ -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`.
+1
View File
@@ -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
+3
View File
@@ -30,6 +30,9 @@ github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4 h1
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4/go.mod h1:H66QhXPJpUSdWschhL6u//v3ge96/qMnQ9mWp3efbxA=
github.com/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=
+9 -6
View File
@@ -12,12 +12,13 @@ import (
// --- Страница просмотра одной загрузки ---
type downloadDetailView struct {
ID int64
ID string
Title string
SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте»
SourceFull string // полный источник (magnet) — блок «Информация о торренте»
Infohash string
Infohash string // первый хеш (для шапки)
InfohashShort string
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
Context string
State string
Error string
@@ -65,7 +66,8 @@ func detailTitle(d store.Download, rd *worker.ReviewData) string {
func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "некорректный id", http.StatusBadRequest)
// Невалидный id = несуществующая сущность; в БД не ходим.
http.Error(w, "задача не найдена", http.StatusNotFound)
return
}
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
@@ -85,8 +87,9 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
Title: detailTitle(d, rd),
SourceType: string(d.SourceType),
SourceFull: d.SourceRef,
Infohash: d.Infohash.String,
InfohashShort: shortenHash(d.Infohash.String),
Infohash: d.PrimaryInfohash(),
InfohashShort: shortenHash(d.PrimaryInfohash()),
Infohashes: d.HashList(),
Context: d.Context,
State: string(d.State),
Error: d.ErrorMsg.String,
@@ -126,7 +129,7 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
// Живая статистика раздачи — со значениями уже в первом кадре; секция
// деградирует (пустой контейнер), если торрент не сидирует/данных нет.
l, ok := s.deps.Live.Live(d.Infohash.String)
l, ok := s.liveFor(d)
view.Seeding = buildSeeding(id, l, ok)
s.render(w, "download.html", view)
+51 -39
View File
@@ -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()))
}
+101 -91
View File
@@ -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)
}
}
+9 -9
View File
@@ -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
+16 -12
View File
@@ -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)
}
}
+43 -18
View File
@@ -23,7 +23,7 @@ func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { r
func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) {
return s.list, len(s.list), nil
}
func (s stubReader) GetDownload(context.Context, int64) (*store.Download, error) {
func (s stubReader) GetDownload(context.Context, string) (*store.Download, error) {
if s.one == nil {
return nil, store.ErrNotFound
}
@@ -33,23 +33,23 @@ func (s stubReader) GetDownload(context.Context, int64) (*store.Download, error)
// stubReviewer — Reviewer-заглушка (нужна для /download/{id}).
type stubReviewer struct{ data *worker.ReviewData }
func (s stubReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) {
func (s stubReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
if s.data == nil {
return nil, store.ErrNotFound
}
return s.data, nil
}
func (stubReviewer) Apply(context.Context, int64) error { return nil }
func (stubReviewer) Refine(context.Context, int64, string) error { return nil }
func (stubReviewer) SetType(context.Context, int64, string) error { return nil }
func (stubReviewer) IgnoreFile(context.Context, int64, string) error { return nil }
func (stubReviewer) Defer(context.Context, int64) error { return nil }
func (stubReviewer) Undo(context.Context, int64) error { return nil }
func (stubReviewer) Relink(context.Context, int64) error { return nil }
func (stubReviewer) Rerecognize(context.Context, int64) error { return nil }
func (stubReviewer) ChooseCandidate(context.Context, int64, int64) error { return nil }
func (stubReviewer) SetProviderID(context.Context, int64, string, string) error { return nil }
func (stubReviewer) ClearProvider(context.Context, int64) error { return nil }
func (stubReviewer) Apply(context.Context, string) error { return nil }
func (stubReviewer) Refine(context.Context, string, string) error { return nil }
func (stubReviewer) SetType(context.Context, string, string) error { return nil }
func (stubReviewer) IgnoreFile(context.Context, string, string) error { return nil }
func (stubReviewer) Defer(context.Context, string) error { return nil }
func (stubReviewer) Undo(context.Context, string) error { return nil }
func (stubReviewer) Relink(context.Context, string) error { return nil }
func (stubReviewer) Rerecognize(context.Context, string) error { return nil }
func (stubReviewer) ChooseCandidate(context.Context, string, string) error { return nil }
func (stubReviewer) SetProviderID(context.Context, string, string, string) error { return nil }
func (stubReviewer) ClearProvider(context.Context, string) error { return nil }
// stubLive — заглушка источника живой телеметрии.
type stubLive struct{ m map[string]worker.Live }
@@ -85,9 +85,14 @@ func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
return rr
}
// testULID — валидный lowercase-ULID для маршрутов (pathID валидирует формат).
const testULID = "01arz3ndektsv4rrffq69g5fav"
// TestRouterRendersPages проверяет, что шаблоны парсятся и страницы рендерятся.
func TestRouterRendersPages(t *testing.T) {
dl := store.Download{ID: 7, SourceRef: "Fargo.S02", Infohash: store.NullString("a1b2c3d4e5f6a7b8"), State: store.StateReview}
dl := store.Download{ID: testULID, SourceRef: "Fargo.S02",
Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "a1b2c3d4e5f6a7b8", Kind: store.HashV1}},
State: store.StateReview}
h := testRouter(t,
stubReader{list: []store.Download{dl}, one: &dl},
stubReviewer{data: &worker.ReviewData{Download: dl}},
@@ -99,8 +104,8 @@ func TestRouterRendersPages(t *testing.T) {
t.Errorf("index не содержит бейдж статуса")
}
if rr := get(t, h, "/download/7"); rr.Code != http.StatusOK {
t.Fatalf("GET /download/7 = %d, want 200", rr.Code)
if rr := get(t, h, "/download/"+testULID); rr.Code != http.StatusOK {
t.Fatalf("GET /download/{id} = %d, want 200", rr.Code)
}
}
@@ -122,7 +127,27 @@ func TestStaticServed(t *testing.T) {
// TestDownloadNotFound — несуществующая загрузка → 404.
func TestDownloadNotFound(t *testing.T) {
h := testRouter(t, stubReader{}, stubReviewer{})
if rr := get(t, h, "/download/999"); rr.Code != http.StatusNotFound {
t.Fatalf("GET /download/999 = %d, want 404", rr.Code)
if rr := get(t, h, "/download/01arz3ndektsv4rrffq69g5fff"); rr.Code != http.StatusNotFound {
t.Fatalf("GET /download/{missing} = %d, want 404", rr.Code)
}
}
// TestDownloadInvalidID — синтаксически невалидный id → 404 без похода в БД.
func TestDownloadInvalidID(t *testing.T) {
h := testRouter(t, stubReader{}, stubReviewer{})
for _, path := range []string{"/download/999", "/download/abc!!!", "/review/12"} {
if rr := get(t, h, path); rr.Code != http.StatusNotFound {
t.Fatalf("GET %s = %d, want 404 (невалидный id = несуществующая сущность)", path, rr.Code)
}
}
}
// TestDownloadUppercaseIDNormalized — uppercase-вариант id ведёт на ту же
// страницу (нормализация на входной границе).
func TestDownloadUppercaseIDNormalized(t *testing.T) {
dl := store.Download{ID: testULID, SourceRef: "X", State: store.StateReview}
h := testRouter(t, stubReader{one: &dl}, stubReviewer{data: &worker.ReviewData{Download: dl}})
if rr := get(t, h, "/download/"+strings.ToUpper(testULID)); rr.Code != http.StatusOK {
t.Fatalf("GET /download/{UPPERCASE} = %d, want 200", rr.Code)
}
}
+29 -26
View File
@@ -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)
}
+50
View File
@@ -0,0 +1,50 @@
// Package ident — единственная точка генерации и разбора идентификаторов
// домена. Идентификатор — ULID (26 символов Crockford base32), канонический
// вид — lowercase; на входных границах (URL, формы) значение прогоняется
// через Parse до обращения к хранилищу, потому что сравнение строк в SQLite
// побайтовое.
package ident
import (
"crypto/rand"
"fmt"
"strings"
"sync"
"time"
"github.com/oklog/ulid/v2"
)
// entropy — monotonic-источник: при равной миллисекунде timestamp'а энтропия
// инкрементируется, так что id, выданные подряд, сохраняют порядок выдачи.
var (
mu sync.Mutex
entropy = ulid.Monotonic(rand.Reader, 0)
)
// NewID возвращает новый идентификатор (текущее время).
func NewID() string {
return NewIDAt(time.Now())
}
// NewIDAt возвращает идентификатор с timestamp-частью из t. Используется
// миграциями для бэкфилла: сортировка id сохраняет историческую хронологию
// (created_at имеет секундное разрешение — равные метки упорядочивает
// monotonic-энтропия в порядке вызовов).
func NewIDAt(t time.Time) string {
mu.Lock()
defer mu.Unlock()
id := ulid.MustNew(ulid.Timestamp(t.UTC()), entropy)
return strings.ToLower(id.String())
}
// Parse валидирует внешний идентификатор и нормализует его к каноническому
// lowercase-виду. Регистр входа не важен (base32 ULID case-insensitive).
func Parse(s string) (string, error) {
s = strings.TrimSpace(s)
u, err := ulid.ParseStrict(strings.ToUpper(s))
if err != nil {
return "", fmt.Errorf("ident: parse %q: %w", s, err)
}
return strings.ToLower(u.String()), nil
}
+70
View File
@@ -0,0 +1,70 @@
package ident
import (
"strings"
"testing"
"time"
)
func TestNewIDLowercaseAndValid(t *testing.T) {
id := NewID()
if len(id) != 26 {
t.Fatalf("len(%q) = %d, want 26", id, len(id))
}
if id != strings.ToLower(id) {
t.Fatalf("id %q is not lowercase", id)
}
if _, err := Parse(id); err != nil {
t.Fatalf("Parse(NewID()) failed: %v", err)
}
}
func TestNewIDSortedByIssueOrder(t *testing.T) {
prev := NewID()
for range 100 {
next := NewID()
if next <= prev {
t.Fatalf("ids out of order: %q then %q", prev, next)
}
prev = next
}
}
func TestNewIDAtEqualTimestampsKeepOrder(t *testing.T) {
ts := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
prev := NewIDAt(ts)
for range 100 {
next := NewIDAt(ts) // одна и та же миллисекунда → monotonic-энтропия
if next <= prev {
t.Fatalf("ids out of order at equal timestamp: %q then %q", prev, next)
}
prev = next
}
}
func TestNewIDAtChronology(t *testing.T) {
older := NewIDAt(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
newer := NewIDAt(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC))
if older >= newer {
t.Fatalf("chronology broken: %q >= %q", older, newer)
}
}
func TestParseNormalizesCase(t *testing.T) {
id := NewID()
got, err := Parse(strings.ToUpper(id))
if err != nil {
t.Fatalf("Parse(upper) failed: %v", err)
}
if got != id {
t.Fatalf("Parse(upper) = %q, want %q", got, id)
}
}
func TestParseRejectsGarbage(t *testing.T) {
for _, bad := range []string{"", "abc", "abc!!!", "123", strings.Repeat("z", 26), strings.Repeat("a", 27)} {
if _, err := Parse(bad); err == nil {
t.Fatalf("Parse(%q) unexpectedly succeeded", bad)
}
}
}
+61 -24
View File
@@ -24,9 +24,17 @@ const errCodeQbitAdd = "qbit_add"
// Store — нужная ingest часть хранилища.
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,
}
}
+55 -18
View File
@@ -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("уведомление о падении приёма не пришло")
+10 -6
View File
@@ -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
+18
View File
@@ -47,6 +47,24 @@ func TestParse(t *testing.T) {
}
}
// Гибридный magnet несёт оба хеша: Infohash — v1 (приоритет), Infohashes — оба.
func TestParseHybridKeepsBothHashes(t *testing.T) {
const (
v1 = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
)
got, err := Parse("magnet:?xt=urn:btih:" + v1 + "&xt=urn:btmh:1220" + v2)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got.Infohash != v1 {
t.Errorf("primary = %q, want v1", got.Infohash)
}
if len(got.Infohashes) != 2 || got.Infohashes[0] != v1 || got.Infohashes[1] != v2 {
t.Errorf("infohashes = %v, want [v1 v2]", got.Infohashes)
}
}
func TestParseErrors(t *testing.T) {
cases := []string{
"https://example.com/file.torrent", // не magnet
+382 -78
View File
@@ -8,6 +8,10 @@ import (
"slices"
"strings"
"time"
"github.com/jmoiron/sqlx"
"git.vakhrushev.me/av/jellybit/internal/ident"
)
// State — состояние загрузки в машине состояний (см. architecture.md).
@@ -35,27 +39,26 @@ const (
)
// terminalStates — единый список окончательно остановленных состояний:
// источник истины и для IsTerminal, и для выборки «активных» задач
// (FindActiveByInfohash). Любое новое терминальное состояние добавляется
// ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key
// снимается по IsTerminal, а активность считалась бы по другому списку).
// источник истины и для IsTerminal, и для выборки «активных» задач.
// Активность выводится ТОЛЬКО из state (отдельного ключа идемпотентности
// нет); любое новое терминальное состояние добавляется ТОЛЬКО сюда — иначе
// семантика «активности» разъедется.
//
// Состояния рассинхрона (target_missing/orphaned/deleted), а также
// failed/stuck — терминальны для idempotency_key, но не «мертвы»: дальше
// двигает либо человек (relink из target_missing, retry из failed/stuck), либо
// фоновая сверка (healing/прогрессия desync; авто-восстановление failed/stuck
// при оживлении источника, см. state-reconciliation) — напрямую через
// SetDownloadState, который восстановит ключ для нетерминального целевого
// состояния.
// Состояния рассинхрона (target_missing/orphaned/deleted), а также failed —
// терминальны для активности, но не «мертвы»: дальше двигает либо человек
// (relink из target_missing, retry из failed), либо фоновая сверка (healing/
// прогрессия desync; авто-восстановление при оживлении источника, см.
// state-reconciliation) — через ActivateIfNoOtherActive, который атомарно
// проверяет инвариант «не более одной активной загрузки на infohash».
var terminalStates = []State{
StateDone, StateCancelled, StateFailed, StateReverted,
StateTargetMissing, StateOrphaned, StateDeleted,
}
// IsTerminal сообщает, завершена ли задача окончательно. Для терминальных
// состояний снимается ключ идемпотентности — тот же infohash можно завести
// заново новой задачей (см. architecture.md, «повторное добавление»).
// stuck терминальным не считается: задача восстановима (retry).
// IsTerminal сообщает, завершена ли задача окончательно. Терминальная задача
// не «владеет» своими инфохэшами — тот же infohash можно завести заново
// новой задачей (см. architecture.md, «повторное добавление»). stuck
// терминальным не считается: задача восстановима (retry).
func (s State) IsTerminal() bool {
return slices.Contains(terminalStates, s)
}
@@ -69,18 +72,44 @@ const (
SourceURL SourceType = "url"
)
// Виды инфохэша (download_infohash.kind).
const (
HashV1 = "v1" // SHA-1, 40 hex
HashV2 = "v2" // SHA-256, 64 hex
)
// HashKind — вид инфохэша по длине hex-строки: 64 — v2, иначе v1.
func HashKind(h string) string {
if len(h) == 64 {
return HashV2
}
return HashV1
}
// NormalizeHash приводит инфохэш к канонической форме хранения (нижний hex —
// в этом же виде его отдаёт qBittorrent).
func NormalizeHash(h string) string {
return strings.ToLower(strings.TrimSpace(h))
}
// Infohash — строка таблицы download_infohash: один из хешей загрузки
// (у одной загрузки их несколько: v1/v2 гибридного торрента).
type Infohash struct {
DownloadID string `db:"download_id"`
Infohash string `db:"infohash"`
Kind string `db:"kind"`
}
// Download — строка таблицы download.
type Download struct {
ID int64 `db:"id"`
SourceType SourceType `db:"source_type"`
SourceRef string `db:"source_ref"`
DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI
Context string `db:"context"`
Infohash sql.NullString `db:"infohash"`
IdempotencyKey sql.NullString `db:"idempotency_key"`
State State `db:"state"`
ErrorCode sql.NullString `db:"error_code"`
ErrorMsg sql.NullString `db:"error_msg"`
ID string `db:"id"` // ULID (lowercase), публичный ключ домена
SourceType SourceType `db:"source_type"`
SourceRef string `db:"source_ref"`
DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI
Context string `db:"context"`
State State `db:"state"`
ErrorCode sql.NullString `db:"error_code"`
ErrorMsg sql.NullString `db:"error_msg"`
// SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в
// qBittorrent (дебаунс пропажи источника, см. state-reconciliation).
SourceMissCount int `db:"source_miss_count"`
@@ -91,12 +120,34 @@ type Download struct {
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
// Infohashes — хеши загрузки (download_infohash); подгружаются вместе с
// записью методами чтения store (v1 раньше v2 — порядок стабильный).
Infohashes []Infohash `db:"-"`
// RecTitle — распознанное название текущей попытки (LEFT JOIN recognition).
// Заполняется только листингом ListDownloadsPage для фолбека заголовка; в
// прочих выборках остаётся пустым.
RecTitle sql.NullString `db:"rec_title"`
}
// HashList — все хеши загрузки списком (для сопоставления с qBittorrent).
func (d Download) HashList() []string {
out := make([]string, len(d.Infohashes))
for i, h := range d.Infohashes {
out[i] = h.Infohash
}
return out
}
// PrimaryInfohash — первый известный хеш (v1 приоритетно) для показа и
// scoped-логгера; пустая строка, если хешей нет.
func (d Download) PrimaryInfohash() string {
if len(d.Infohashes) == 0 {
return ""
}
return d.Infohashes[0].Infohash
}
// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC).
const sqliteTimeLayout = "2006-01-02 15:04:05"
@@ -121,31 +172,163 @@ func NullString(s string) sql.NullString {
return sql.NullString{String: s, Valid: s != ""}
}
// CreateDownload вставляет загрузку и возвращает её id.
func (s *Store) CreateDownload(ctx context.Context, d *Download) (int64, error) {
// CreateDownloadIfNoActive атомарно (одна write-транзакция, BEGIN IMMEDIATE
// через _txlock) проверяет инвариант «не более одной активной загрузки на
// infohash» и заводит загрузку: если активная задача с любым из hashes уже
// есть — возвращает её (дедуп, ничего не создавая); иначе вставляет d с новым
// ULID и его хешами и возвращает (nil, nil). d.ID и d.Infohashes заполняются.
func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashes []string) (*Download, error) {
norm := normalizeHashes(hashes)
if len(norm) == 0 {
return nil, fmt.Errorf("create download: no infohash")
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("create download: begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
existing, err := findActiveByInfohash(ctx, tx, norm, "")
if err != nil {
return nil, fmt.Errorf("create download: %w", err)
}
if existing != nil {
// Дедуп нашёл активного владельца по одному из хешей — остальные хеши
// norm принадлежат тому же торренту (гибридный magnet): дописываем
// недостающие, иначе второй хеш молча теряется и последующий приём по
// нему создал бы вторую активную задачу.
for _, h := range norm {
if _, err := tx.ExecContext(ctx,
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`,
existing.ID, h, HashKind(h)); err != nil {
return nil, fmt.Errorf("create download: top up infohash: %w", err)
}
}
if err := attachInfohashesOne(ctx, tx, existing); err != nil {
return nil, fmt.Errorf("create download: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("create download: commit dedup: %w", err)
}
return existing, nil
}
d.ID = ident.NewID()
const q = `
INSERT INTO download (source_type, source_ref, display_name, context, infohash, idempotency_key, state)
VALUES (?, ?, ?, ?, ?, ?, ?)`
res, err := s.DB.ExecContext(ctx, q,
d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.Infohash, d.IdempotencyKey, d.State)
if err != nil {
return 0, fmt.Errorf("insert download: %w", err)
INSERT INTO download (id, source_type, source_ref, display_name, context, state)
VALUES (?, ?, ?, ?, ?, ?)`
if _, err := tx.ExecContext(ctx, q,
d.ID, d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.State); err != nil {
return nil, fmt.Errorf("insert download: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("download last insert id: %w", err)
d.Infohashes = d.Infohashes[:0]
for _, h := range norm {
if _, err := tx.ExecContext(ctx,
`INSERT INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`,
d.ID, h, HashKind(h)); err != nil {
return nil, fmt.Errorf("insert download infohash: %w", err)
}
d.Infohashes = append(d.Infohashes, Infohash{DownloadID: d.ID, Infohash: h, Kind: HashKind(h)})
}
return id, nil
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("create download: commit: %w", err)
}
return nil, nil
}
// GetDownload возвращает загрузку по id.
func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
// ActivateIfNoOtherActive атомарно возвращает загрузку в активное состояние
// (retry/восстановление сверкой/relink): в одной write-транзакции проверяет,
// что никакая ДРУГАЯ активная загрузка не владеет любым из хешей этой, и
// переводит состояние. При владении возвращает ErrInfohashTaken (обёрнутый
// с id владельца).
func (s *Store) ActivateIfNoOtherActive(ctx context.Context, id string, state State, errCode, errMsg string) error {
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("activate %s: begin tx: %w", id, err)
}
defer func() { _ = tx.Rollback() }()
var hashes []string
if err := tx.SelectContext(ctx, &hashes,
`SELECT infohash FROM download_infohash WHERE download_id = ?`, id); err != nil {
return fmt.Errorf("activate %s: read hashes: %w", id, err)
}
if len(hashes) > 0 {
// Исключаем саму задачу: при retry из stuck она сама активна и без
// исключения LIMIT 1 мог бы вернуть её, замаскировав другого владельца.
other, err := findActiveByInfohash(ctx, tx, hashes, id)
if err != nil {
return fmt.Errorf("activate %s: %w", id, err)
}
if other != nil {
return fmt.Errorf("activate %s: infohash owned by download %s: %w",
id, other.ID, ErrInfohashTaken)
}
}
if err := setState(ctx, tx, id, state, errCode, errMsg, true); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("activate %s: commit: %w", id, err)
}
return nil
}
// AddInfohashes дописывает загрузке недостающие хеши (qBittorrent раскрыл
// оба хеша гибридного торрента, а приём знал один). Это тоже мутация
// владения хешем, поэтому она под тем же гардом, что и create/activate:
// в одной write-транзакции каждый хеш проверяется на владение ДРУГОЙ
// активной задачей; конфликтные хеши не дописываются, метод возвращает
// ErrInfohashTaken (неконфликтные при этом дописаны — частичный успех).
func (s *Store) AddInfohashes(ctx context.Context, downloadID string, hashes []string) error {
norm := normalizeHashes(hashes)
if len(norm) == 0 {
return nil
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("add infohashes to %s: begin tx: %w", downloadID, err)
}
defer func() { _ = tx.Rollback() }()
var taken []string
for _, h := range norm {
other, err := findActiveByInfohash(ctx, tx, []string{h}, downloadID)
if err != nil {
return fmt.Errorf("add infohashes to %s: %w", downloadID, err)
}
if other != nil {
taken = append(taken, h)
continue
}
if _, err := tx.ExecContext(ctx,
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`,
downloadID, h, HashKind(h)); err != nil {
return fmt.Errorf("add infohash %s to %s: %w", h, downloadID, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("add infohashes to %s: commit: %w", downloadID, err)
}
if len(taken) > 0 {
return fmt.Errorf("add infohashes to %s: %v owned by another active download: %w",
downloadID, taken, ErrInfohashTaken)
}
return nil
}
// GetDownload возвращает загрузку по id (с хешами).
func (s *Store) GetDownload(ctx context.Context, id string) (*Download, error) {
var d Download
if err := s.DB.GetContext(ctx, &d, `SELECT * FROM download WHERE id = ?`, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get download %d: %w", id, ErrNotFound)
return nil, fmt.Errorf("get download %s: %w", id, ErrNotFound)
}
return nil, fmt.Errorf("get download %d: %w", id, err)
return nil, fmt.Errorf("get download %s: %w", id, err)
}
if err := attachInfohashesOne(ctx, s.DB, &d); err != nil {
return nil, fmt.Errorf("get download %s: %w", id, err)
}
return &d, nil
}
@@ -153,20 +336,24 @@ func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
// SetSourceAddedAt однократно фиксирует время добавления торрента в источник
// (qBittorrent added_on). SQL-гард `source_added_at IS NULL` не даёт перезапи-
// сать значение на повторных наблюдениях: время добавления неизменно.
func (s *Store) SetSourceAddedAt(ctx context.Context, id int64, t time.Time) error {
func (s *Store) SetSourceAddedAt(ctx context.Context, id string, t time.Time) error {
const q = `UPDATE download SET source_added_at = ? WHERE id = ? AND source_added_at IS NULL`
if _, err := s.DB.ExecContext(ctx, q, FormatTime(t), id); err != nil {
return fmt.Errorf("set source added at %d: %w", id, err)
return fmt.Errorf("set source added at %s: %w", id, err)
}
return nil
}
// ListDownloads возвращает все загрузки, новые сверху.
// ListDownloads возвращает все загрузки, новые сверху (id — ULID, сортировка
// по нему хронологична).
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
var out []Download
if err := s.DB.SelectContext(ctx, &out, `SELECT * FROM download ORDER BY id DESC`); err != nil {
return nil, fmt.Errorf("list downloads: %w", err)
}
if err := s.attachInfohashes(ctx, out); err != nil {
return nil, fmt.Errorf("list downloads: %w", err)
}
return out, nil
}
@@ -186,6 +373,9 @@ func (s *Store) ListDownloadsByState(ctx context.Context, states ...State) ([]Do
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list downloads by state: %w", err)
}
if err := s.attachInfohashes(ctx, out); err != nil {
return nil, fmt.Errorf("list downloads by state: %w", err)
}
return out, nil
}
@@ -210,25 +400,52 @@ func (s *Store) ListRecoverable(ctx context.Context, codes ...string) ([]Downloa
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list recoverable: %w", err)
}
if err := s.attachInfohashes(ctx, out); err != nil {
return nil, fmt.Errorf("list recoverable: %w", err)
}
return out, nil
}
// FindActiveByInfohash возвращает незавершённую задачу для infohash либо
// (nil, nil), если её нет. Основа идемпотентного приёма.
func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) {
// «Активна» = не в терминальном состоянии. Список — единый с IsTerminal
// (terminalStates), иначе семантика активности разъедется с idempotency_key.
ph := make([]string, len(terminalStates))
args := make([]any, 0, len(terminalStates)+1)
args = append(args, infohash)
for i, st := range terminalStates {
ph[i] = "?"
args = append(args, string(st))
// FindActiveByInfohash возвращает незавершённую задачу, владеющую любым из
// hashes, либо (nil, nil). Читающая основа дедупа; сам инвариант держат
// guarded-методы (CreateDownloadIfNoActive / ActivateIfNoOtherActive).
func (s *Store) FindActiveByInfohash(ctx context.Context, hashes ...string) (*Download, error) {
d, err := findActiveByInfohash(ctx, s.DB, normalizeHashes(hashes), "")
if err != nil {
return nil, err
}
q := `SELECT * FROM download WHERE infohash = ? AND state NOT IN (` +
strings.Join(ph, ",") + `) ORDER BY id DESC LIMIT 1`
if d != nil {
if err := attachInfohashesOne(ctx, s.DB, d); err != nil {
return nil, err
}
}
return d, nil
}
// findActiveByInfohash — общая выборка «активная задача по любому из хешей»
// (для guarded-методов — внутри их транзакции). hashes уже нормализованы;
// excludeID исключает саму проверяемую задачу (она может быть активной,
// например stuck при retry, — и не должна маскировать другого владельца);
// пустой excludeID — без исключения. Хеши найденной загрузки НЕ подгружаются.
func findActiveByInfohash(ctx context.Context, q sqlx.QueryerContext, hashes []string, excludeID string) (*Download, error) {
if len(hashes) == 0 {
return nil, nil
}
// «Активна» = не в терминальном состоянии. Список — единый с IsTerminal
// (terminalStates), иначе семантика активности разъедется.
var args []any
hashPh := placeholders(&args, hashes)
statePh := placeholders(&args, terminalStates)
query := `SELECT download.* FROM download
JOIN download_infohash dh ON dh.download_id = download.id
WHERE dh.infohash IN (` + hashPh + `) AND download.state NOT IN (` + statePh + `)`
if excludeID != "" {
query += ` AND download.id != ?`
args = append(args, excludeID)
}
query += ` ORDER BY download.id DESC LIMIT 1`
var d Download
err := s.DB.GetContext(ctx, &d, q, args...)
err := sqlx.GetContext(ctx, q, &d, query, args...)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -238,63 +455,150 @@ func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Dow
return &d, nil
}
// placeholders дописывает значения in в args и возвращает строку "?,?,…"
// той же длины — сборка IN-списков без ручного жонглирования срезами.
func placeholders[T ~string](args *[]any, in []T) string {
ph := make([]string, len(in))
for i, v := range in {
ph[i] = "?"
*args = append(*args, string(v))
}
return strings.Join(ph, ",")
}
// ExistsByInfohash сообщает, есть ли хоть одна загрузка (в любом состоянии)
// с данным infohash. Discovery усыновляет раздачу только если её ещё не
// с любым из hashes. Discovery усыновляет раздачу только если её ещё не
// видели — так готовые задачи не переобрабатываются на каждом тике.
func (s *Store) ExistsByInfohash(ctx context.Context, infohash string) (bool, error) {
func (s *Store) ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error) {
norm := normalizeHashes(hashes)
if len(norm) == 0 {
return false, nil
}
ph := make([]string, len(norm))
args := make([]any, len(norm))
for i, h := range norm {
ph[i] = "?"
args[i] = h
}
var n int
if err := s.DB.GetContext(ctx, &n,
`SELECT COUNT(1) FROM download WHERE infohash = ?`, infohash); err != nil {
`SELECT COUNT(1) FROM download_infohash WHERE infohash IN (`+strings.Join(ph, ",")+`)`,
args...); err != nil {
return false, fmt.Errorf("exists by infohash: %w", err)
}
return n > 0, nil
}
// SetDownloadState переводит загрузку в новое состояние. Ключ
// идемпотентности пересчитывается из текущего infohash: для терминального
// состояния снимается (NULL), иначе равен infohash — так partial unique
// index гарантирует не более одной активной задачи на infohash.
func (s *Store) SetDownloadState(ctx context.Context, id int64, state State, errCode, errMsg string) error {
const q = `
// SetDownloadState переводит загрузку в новое состояние. Механический
// бэкстоп инварианта «одна активная загрузка на infohash» (заменяет
// удалённый unique-индекс): переход из терминального состояния в активное
// этим методом отклоняется — возврат в активное идёт ТОЛЬКО через
// ActivateIfNoOtherActive, который проверяет владение хешами.
func (s *Store) SetDownloadState(ctx context.Context, id string, state State, errCode, errMsg string) error {
return setState(ctx, s.DB, id, state, errCode, errMsg, false)
}
// setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу.
func setState(ctx context.Context, e sqlx.ExecerContext, id string, state State, errCode, errMsg string, reviveOK bool) error {
q := `
UPDATE download
SET state = ?,
error_code = ?,
error_msg = ?,
idempotency_key = CASE WHEN ? = 1 THEN NULL ELSE infohash END,
updated_at = datetime('now')
WHERE id = ?`
terminal := 0
if state.IsTerminal() {
terminal = 1
args := []any{string(state), nullArg(errCode), nullArg(errMsg), id}
if !reviveOK && !state.IsTerminal() {
q += ` AND state NOT IN (` + placeholders(&args, terminalStates) + `)`
}
res, err := s.DB.ExecContext(ctx, q, string(state), nullArg(errCode), nullArg(errMsg), terminal, id)
res, err := e.ExecContext(ctx, q, args...)
if err != nil {
return fmt.Errorf("set download %d state %q: %w", id, state, err)
return fmt.Errorf("set download %s state %q: %w", id, state, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("set download %d state %q: %w", id, state, err)
return fmt.Errorf("set download %s state %q: %w", id, state, err)
}
if n == 0 {
return fmt.Errorf("set download %d state %q: not found", id, state)
return fmt.Errorf("set download %s state %q: not found or terminal (revive requires ActivateIfNoOtherActive)", id, state)
}
return nil
}
// SetSourceMissCount записывает счётчик пропусков источника (дебаунс сверки).
// Состояние не трогает — это отдельная от перехода фоновая отметка.
func (s *Store) SetSourceMissCount(ctx context.Context, id int64, n int) error {
func (s *Store) SetSourceMissCount(ctx context.Context, id string, n int) error {
res, err := s.DB.ExecContext(ctx,
`UPDATE download SET source_miss_count = ? WHERE id = ?`, n, id)
if err != nil {
return fmt.Errorf("set download %d source_miss_count: %w", id, err)
return fmt.Errorf("set download %s source_miss_count: %w", id, err)
}
if affected, _ := res.RowsAffected(); affected == 0 {
return fmt.Errorf("set download %d source_miss_count: not found", id)
return fmt.Errorf("set download %s source_miss_count: not found", id)
}
return nil
}
// attachInfohashes подгружает хеши для набора загрузок батч-запросами
// (порядок стабильный: v1 раньше v2). IN-список режется на чанки, чтобы
// безлимитные выборки (ListDownloads за всю историю) не упирались в
// SQLITE_MAX_VARIABLE_NUMBER.
func (s *Store) attachInfohashes(ctx context.Context, ds []Download) error {
if len(ds) == 0 {
return nil
}
const chunkSize = 500
byID := make(map[string][]Infohash, len(ds))
for start := 0; start < len(ds); start += chunkSize {
end := min(start+chunkSize, len(ds))
var args []any
ids := make([]string, 0, end-start)
for i := start; i < end; i++ {
ids = append(ids, ds[i].ID)
}
ph := placeholders(&args, ids)
var rows []Infohash
if err := s.DB.SelectContext(ctx, &rows,
`SELECT download_id, infohash, kind FROM download_infohash
WHERE download_id IN (`+ph+`) ORDER BY kind, infohash`,
args...); err != nil {
return fmt.Errorf("attach infohashes: %w", err)
}
for _, r := range rows {
byID[r.DownloadID] = append(byID[r.DownloadID], r)
}
}
for i := range ds {
ds[i].Infohashes = byID[ds[i].ID]
}
return nil
}
// attachInfohashesOne подгружает хеши одной загрузки (в т.ч. внутри tx).
func attachInfohashesOne(ctx context.Context, q sqlx.QueryerContext, d *Download) error {
if err := sqlx.SelectContext(ctx, q, &d.Infohashes,
`SELECT download_id, infohash, kind FROM download_infohash
WHERE download_id = ? ORDER BY kind, infohash`, d.ID); err != nil {
return fmt.Errorf("attach infohashes: %w", err)
}
return nil
}
// normalizeHashes нормализует и дедуплицирует хеши, отбрасывая пустые.
func normalizeHashes(hashes []string) []string {
out := make([]string, 0, len(hashes))
for _, h := range hashes {
h = NormalizeHash(h)
if h == "" || slices.Contains(out, h) {
continue
}
out = append(out, h)
}
return out
}
// nullArg возвращает nil для пустой строки (чтобы писать NULL, не "").
func nullArg(s string) any {
if s == "" {
+317 -41
View File
@@ -2,6 +2,9 @@ package store
import (
"context"
"errors"
"strings"
"sync"
"testing"
)
@@ -15,24 +18,39 @@ func newTestStore(t *testing.T) *Store {
return st
}
func newDownloading(infohash string) *Download {
func newDownloading() *Download {
return &Download{
SourceType: SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash,
Context: "ctx",
Infohash: NullString(infohash),
IdempotencyKey: NullString(infohash),
State: StateDownloading,
SourceType: SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:test",
Context: "ctx",
State: StateDownloading,
}
}
// mustCreate заводит загрузку с хешем и возвращает её id; дедуп на
// существующую активную — ошибка теста.
func mustCreate(t *testing.T, st *Store, infohash string) string {
t.Helper()
d := newDownloading()
existing, err := st.CreateDownloadIfNoActive(context.Background(), d, []string{infohash})
if err != nil {
t.Fatalf("create: %v", err)
}
if existing != nil {
t.Fatalf("unexpected dedup to %s", existing.ID)
}
return d.ID
}
func TestCreateAndGetDownload(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
id, err := st.CreateDownload(ctx, newDownloading("aabbccddeeff00112233445566778899aabbccdd"))
if err != nil {
t.Fatalf("create: %v", err)
const ih = "AABBCCDDEEFF00112233445566778899aabbccdd" // смешанный регистр — нормализуется
id := mustCreate(t, st, ih)
if len(id) != 26 || id != strings.ToLower(id) {
t.Errorf("id = %q, want lowercase ULID (26 chars)", id)
}
got, err := st.GetDownload(ctx, id)
@@ -48,6 +66,12 @@ func TestCreateAndGetDownload(t *testing.T) {
if got.CreatedAt == "" {
t.Error("created_at пуст")
}
if len(got.Infohashes) != 1 {
t.Fatalf("infohashes = %v, want 1", got.Infohashes)
}
if h := got.Infohashes[0]; h.Infohash != strings.ToLower(ih) || h.Kind != HashV1 {
t.Errorf("infohash = %+v, want lowercase v1", h)
}
}
func TestFindActiveByInfohash(t *testing.T) {
@@ -59,10 +83,7 @@ func TestFindActiveByInfohash(t *testing.T) {
t.Fatalf("ожидался (nil,nil), получили (%v,%v)", d, err)
}
id, err := st.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatal(err)
}
id := mustCreate(t, st, ih)
d, err := st.FindActiveByInfohash(ctx, ih)
if err != nil {
t.Fatal(err)
@@ -70,6 +91,9 @@ func TestFindActiveByInfohash(t *testing.T) {
if d == nil || d.ID != id {
t.Fatalf("активная задача не найдена: %v", d)
}
if len(d.Infohashes) != 1 {
t.Fatalf("хеши не подгружены: %v", d.Infohashes)
}
}
// Состояния рассинхрона (target_missing/orphaned/deleted) терминальны: задача
@@ -81,12 +105,9 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
t.Run(string(st), func(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
ih := "33333333333333333333333333333333333333" + string(st[0:2])
const ih = "3333333333333333333333333333333333333333"
id, err := store.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatal(err)
}
id := mustCreate(t, store, ih)
if err := store.SetDownloadState(ctx, id, st, "", ""); err != nil {
t.Fatal(err)
}
@@ -97,17 +118,15 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
}
}
// Терминальное состояние снимает ключ идемпотентности и позволяет завести
// тот же infohash заново (повторная закачка спустя время).
// Терминальное состояние освобождает infohash: тот же хеш заводится заново
// новой задачей (повторная закачка спустя время) — активность выводится
// только из state.
func TestTerminalReleasesInfohash(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "2222222222222222222222222222222222222222"
id, err := st.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatal(err)
}
id := mustCreate(t, st, ih)
if err := st.SetDownloadState(ctx, id, StateFailed, "qbit_add", "boom"); err != nil {
t.Fatal(err)
}
@@ -116,39 +135,291 @@ func TestTerminalReleasesInfohash(t *testing.T) {
if d, err := st.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
t.Fatalf("после failed активная задача не должна находиться: (%v,%v)", d, err)
}
// Ключ идемпотентности снят.
got, err := st.GetDownload(ctx, id)
if err != nil {
t.Fatal(err)
}
if got.IdempotencyKey.Valid {
t.Errorf("idempotency_key должен быть NULL, получили %q", got.IdempotencyKey.String)
}
if got.ErrorCode.String != "qbit_add" {
t.Errorf("error_code = %q", got.ErrorCode.String)
}
// Тот же infohash заводится заново — unique index не мешает.
id2, err := st.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatalf("повторное добавление после терминального должно проходить: %v", err)
}
// Тот же infohash заводится заново новой задачей.
id2 := mustCreate(t, st, ih)
if id2 == id {
t.Error("ожидалась новая задача")
}
}
// Две активные задачи с одним ключом идемпотентности недопустимы.
func TestActiveDuplicateRejected(t *testing.T) {
// Повторный приём при активной задаче дедуплицируется: вторая вставка не
// создаёт строку, а возвращает существующую активную задачу.
func TestActiveDuplicateDeduplicated(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "3333333333333333333333333333333333333333"
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err != nil {
id := mustCreate(t, st, ih)
d := newDownloading()
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err == nil {
t.Error("ожидалось нарушение уникальности idempotency_key")
if existing == nil || existing.ID != id {
t.Fatalf("ожидался дедуп на %s, получили %v", id, existing)
}
if len(existing.Infohashes) != 1 {
t.Fatalf("у существующей задачи не подгружены хеши: %v", existing.Infohashes)
}
}
// Дедуп ловит совпадение по ЛЮБОМУ из хешей: активная задача знает v1+v2,
// новый приём приходит только с v2.
func TestDedupByAnyHash(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const v1 = "4444444444444444444444444444444444444444"
const v2 = "6666666666666666666666666666666666666666666666666666666666666666"
id := mustCreate(t, st, v1)
if err := st.AddInfohashes(ctx, id, []string{v2}); err != nil {
t.Fatal(err)
}
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
if err != nil {
t.Fatal(err)
}
if existing == nil || existing.ID != id {
t.Fatalf("ожидался дедуп по v2-хешу на %s, получили %v", id, existing)
}
// Хеши задачи: v1 раньше v2 (стабильный порядок), повторное добавление
// идемпотентно.
if err := st.AddInfohashes(ctx, id, []string{v2, v1}); err != nil {
t.Fatal(err)
}
got, err := st.GetDownload(ctx, id)
if err != nil {
t.Fatal(err)
}
if len(got.Infohashes) != 2 || got.Infohashes[0].Kind != HashV1 || got.Infohashes[1].Kind != HashV2 {
t.Fatalf("infohashes = %+v, want [v1, v2]", got.Infohashes)
}
if got.PrimaryInfohash() != v1 {
t.Errorf("primary = %q, want v1", got.PrimaryInfohash())
}
}
// ActivateIfNoOtherActive отказывает, когда хешем владеет другая активная
// задача, и пропускает, когда владелец ушёл в терминал.
func TestActivateIfNoOtherActive(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "5555555555555555555555555555555555555555"
id1 := mustCreate(t, st, ih)
if err := st.SetDownloadState(ctx, id1, StateFailed, "magnet_timeout", ""); err != nil {
t.Fatal(err)
}
// Хеш перехватила новая активная задача.
id2 := mustCreate(t, st, ih)
err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", "")
if !errors.Is(err, ErrInfohashTaken) {
t.Fatalf("ожидался ErrInfohashTaken, получили %v", err)
}
if d, _ := st.GetDownload(ctx, id1); d.State != StateFailed {
t.Fatalf("задача не должна была активироваться: %s", d.State)
}
// Владелец завершился → активация проходит.
if err := st.SetDownloadState(ctx, id2, StateDone, "", ""); err != nil {
t.Fatal(err)
}
if err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", ""); err != nil {
t.Fatalf("активация после ухода владельца: %v", err)
}
if d, _ := st.GetDownload(ctx, id1); d.State != StateDownloading {
t.Fatalf("state = %s, want downloading", d.State)
}
}
// Дедуп-ветка дописывает существующей задаче недостающие хеши гибридного
// вызова — второй хеш не теряется, и последующий приём по нему дедупится.
func TestCreateDedupTopsUpHashes(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const v1 = "aaaa111111111111111111111111111111111111"
const v2 = "bbbb222222222222222222222222222222222222222222222222222222222222"
id := mustCreate(t, st, v1)
// Гибридный вызов с {v1, v2} дедупится на задачу и доносит ей v2.
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2})
if err != nil {
t.Fatal(err)
}
if existing == nil || existing.ID != id {
t.Fatalf("ожидался дедуп на %s, получили %v", id, existing)
}
if len(existing.Infohashes) != 2 {
t.Fatalf("хеши existing = %+v, want v1+v2 (top-up)", existing.Infohashes)
}
// Теперь приём только по v2 тоже дедупится, а не создаёт вторую задачу.
byV2, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
if err != nil {
t.Fatal(err)
}
if byV2 == nil || byV2.ID != id {
t.Fatalf("дедуп по донесённому v2 не сработал: %v", byV2)
}
}
// AddInfohashes под гардом: хеш, которым владеет другая активная задача,
// не дописывается — возвращается ErrInfohashTaken.
func TestAddInfohashesGuard(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const h1 = "cccc111111111111111111111111111111111111"
const h2 = "dddd222222222222222222222222222222222222"
a := mustCreate(t, st, h1)
_ = mustCreate(t, st, h2) // активный владелец h2
err := st.AddInfohashes(ctx, a, []string{h2})
if !errors.Is(err, ErrInfohashTaken) {
t.Fatalf("ожидался ErrInfohashTaken, получили %v", err)
}
got, _ := st.GetDownload(ctx, a)
if len(got.Infohashes) != 1 || got.Infohashes[0].Infohash != h1 {
t.Fatalf("чужой хеш не должен был дописаться: %+v", got.Infohashes)
}
// Хеш терминального владельца дописывается свободно.
if err := st.SetDownloadState(ctx, a, StateDone, "", ""); err != nil {
t.Fatal(err)
}
b := mustCreate(t, st, "eeee333333333333333333333333333333333333")
if err := st.AddInfohashes(ctx, b, []string{h1}); err != nil {
t.Fatalf("хеш терминальной задачи должен дописываться: %v", err)
}
}
// Гард активации не маскирует конфликт самой активируемой задачей: stuck
// (нетерминальна) с более новым id не должна перекрыть старшего владельца.
func TestActivateExcludesSelf(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const h = "ffff111111111111111111111111111111111111"
older := mustCreate(t, st, h) // активный владелец, id старше
newer := mustCreate(t, st, "0000222222222222222222222222222222222222")
if err := st.SetDownloadState(ctx, newer, StateStuck, "stalled", ""); err != nil {
t.Fatal(err)
}
// Легаси/аварийное состояние: у newer тот же хеш h (мимо API — гард
// такого не создаст, но обязан не маскировать).
if _, err := st.DB.ExecContext(ctx,
`INSERT INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, 'v1')`,
newer, h); err != nil {
t.Fatal(err)
}
err := st.ActivateIfNoOtherActive(ctx, newer, StateDownloading, "", "")
if !errors.Is(err, ErrInfohashTaken) {
t.Fatalf("гард замаскирован self-строкой: ожидался ErrInfohashTaken, получили %v", err)
}
_ = older
}
// Механический бэкстоп: публичный SetDownloadState не оживляет терминальную
// задачу — возврат в активное только через ActivateIfNoOtherActive.
func TestSetDownloadStateRejectsRevive(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
id := mustCreate(t, st, "1234511111111111111111111111111111111111")
if err := st.SetDownloadState(ctx, id, StateFailed, "x", ""); err != nil {
t.Fatal(err)
}
if err := st.SetDownloadState(ctx, id, StateDownloading, "", ""); err == nil {
t.Fatal("терминал→активное мимо гарда должно отклоняться")
}
if d, _ := st.GetDownload(ctx, id); d.State != StateFailed {
t.Fatalf("state = %s, want failed (без изменений)", d.State)
}
// Терминал→терминал разрешён (например, сверка double-terminal переходов).
if err := st.SetDownloadState(ctx, id, StateDeleted, "", ""); err != nil {
t.Fatalf("терминал→терминал должен проходить: %v", err)
}
// Штатный путь оживления работает.
if err := st.ActivateIfNoOtherActive(ctx, id, StateDownloading, "", ""); err != nil {
t.Fatalf("оживление через гард: %v", err)
}
}
// Конкурентные создания одного infohash сериализуются write-транзакциями
// (_txlock=immediate): ровно одна задача создаётся, остальные дедупятся.
func TestConcurrentCreateDedup(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "abcde11111111111111111111111111111111111"
const n = 8
ids := make(chan string, n)
errs := make(chan error, n)
var wg sync.WaitGroup
for range n {
wg.Add(1)
go func() {
defer wg.Done()
d := newDownloading()
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
if err != nil {
errs <- err
return
}
if existing != nil {
ids <- existing.ID
} else {
ids <- d.ID
}
}()
}
wg.Wait()
close(ids)
close(errs)
for err := range errs {
t.Fatalf("конкурентное создание упало: %v", err)
}
uniq := map[string]bool{}
for id := range ids {
uniq[id] = true
}
if len(uniq) != 1 {
t.Fatalf("создано %d разных задач на один infohash, want 1: %v", len(uniq), uniq)
}
all, _ := st.ListDownloads(ctx)
if len(all) != 1 {
t.Fatalf("в БД %d строк, want 1", len(all))
}
}
func TestExistsByInfohash(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "7777777777777777777777777777777777777777"
if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || ok {
t.Fatalf("ожидался (false,nil), получили (%v,%v)", ok, err)
}
id := mustCreate(t, st, ih)
if err := st.SetDownloadState(ctx, id, StateDone, "", ""); err != nil {
t.Fatal(err)
}
// Exists видит и терминальные (в отличие от FindActive).
if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || !ok {
t.Fatalf("ожидался (true,nil), получили (%v,%v)", ok, err)
}
}
@@ -156,8 +427,8 @@ func TestListAndByState(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
id1, _ := st.CreateDownload(ctx, newDownloading("4444444444444444444444444444444444444444"))
id2, _ := st.CreateDownload(ctx, newDownloading("5555555555555555555555555555555555555555"))
id1 := mustCreate(t, st, "4444444444444444444444444444444444444444")
id2 := mustCreate(t, st, "5555555555555555555555555555555555555555")
if err := st.SetDownloadState(ctx, id2, StateCompleted, "", ""); err != nil {
t.Fatal(err)
}
@@ -169,6 +440,11 @@ func TestListAndByState(t *testing.T) {
if len(all) != 2 {
t.Fatalf("ListDownloads = %d, want 2", len(all))
}
for _, d := range all {
if len(d.Infohashes) != 1 {
t.Fatalf("у %s не подгружены хеши", d.ID)
}
}
dl, err := st.ListDownloadsByState(ctx, StateDownloading)
if err != nil {
+6
View File
@@ -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")
+5 -1
View File
@@ -74,7 +74,8 @@ func listWhere(f ListFilter) (string, []any) {
conds = append(conds, "(source_ref LIKE ? COLLATE NOCASE "+
"OR display_name LIKE ? COLLATE NOCASE "+
"OR context LIKE ? COLLATE NOCASE "+
"OR IFNULL(infohash,'') LIKE ? COLLATE NOCASE)")
"OR EXISTS (SELECT 1 FROM download_infohash dh "+
"WHERE dh.download_id = download.id AND dh.infohash LIKE ? COLLATE NOCASE))")
args = append(args, like, like, like, like)
}
@@ -109,5 +110,8 @@ LIMIT ? OFFSET ?`
if err := s.DB.SelectContext(ctx, &out, q, pageArgs...); err != nil {
return nil, 0, fmt.Errorf("list downloads page: %w", err)
}
if err := s.attachInfohashes(ctx, out); err != nil {
return nil, 0, fmt.Errorf("list downloads page: %w", err)
}
return out, total, nil
}
+15 -12
View File
@@ -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)
+142
View File
@@ -0,0 +1,142 @@
package store
import (
"context"
"testing"
"github.com/jmoiron/sqlx"
"github.com/pressly/goose/v3"
)
// TestUlidMigration прогоняет миграцию 0006 на фикстурной БД со старой схемой
// (числовые id, download.infohash + idempotency_key) и проверяет: FK-связи
// сохранены, порядок по id соответствует created_at, хеши разнесены в
// download_infohash, старые столбцы удалены.
func TestUlidMigration(t *testing.T) {
dbPath := t.TempDir() + "/legacy.db"
// Legacy-БД: схема до 0006 + строки с числовыми id.
legacy, err := sqlx.Connect("sqlite",
"file:"+dbPath+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
if err != nil {
t.Fatalf("open legacy: %v", err)
}
goose.SetBaseFS(migrationsFS)
goose.SetLogger(goose.NopLogger())
if err := goose.SetDialect("sqlite3"); err != nil {
t.Fatal(err)
}
if err := goose.UpTo(legacy.DB, "migrations", 5); err != nil {
t.Fatalf("migrate to v5: %v", err)
}
const (
v1hash = "aabbccddeeff00112233445566778899aabbccdd"
v2hash = "6666666666666666666666666666666666666666666666666666666666666666"
)
seed := []string{
// #1 done с v1-хешем (ключ снят терминалом), #2 активная с v2-хешем,
// #3 без хеша; #2 и #3 в одну секунду — порядок должен сохраниться.
`INSERT INTO download (id, source_type, source_ref, display_name, state, infohash, idempotency_key, created_at, updated_at)
VALUES (1, 'magnet', 'magnet:?xt=urn:btih:` + v1hash + `', 'Old One', 'done', '` + v1hash + `', NULL,
'2026-01-01 10:00:00', '2026-01-01 11:00:00')`,
`INSERT INTO download (id, source_type, source_ref, display_name, state, infohash, idempotency_key, created_at, updated_at)
VALUES (2, 'magnet', 'magnet:?xt=urn:btmh:1220` + v2hash + `', 'Two', 'downloading', '` + v2hash + `', '` + v2hash + `',
'2026-02-01 10:00:00', '2026-02-01 10:00:00')`,
`INSERT INTO download (id, source_type, source_ref, display_name, state, infohash, idempotency_key, created_at, updated_at)
VALUES (3, 'magnet', 'magnet:?xt=urn:btih:cafe', 'Three', 'failed', NULL, NULL,
'2026-02-01 10:00:00', '2026-02-01 10:00:00')`,
`INSERT INTO recognition (id, download_id, attempt_no, is_current, title, provider, provider_id, plan)
VALUES (10, 1, 1, 1, 'Fargo', 'tvdb', '269613', '{"type":"series"}')`,
`INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, chosen)
VALUES (20, 10, 'tvdb', '269613', 'Fargo', 1)`,
`INSERT INTO hint (id, download_id, text) VALUES (30, 1, 'второй сезон')`,
`INSERT INTO override (id, download_id, field, value) VALUES (40, 1, 'media_type', 'series')`,
`INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status)
VALUES (50, 1, 'b-1', '/d/a.mkv', '/m/A.mkv', 'video', 'linked')`,
}
for _, q := range seed {
if _, err := legacy.Exec(q); err != nil {
t.Fatalf("seed: %v\n%s", err, q)
}
}
if err := legacy.Close(); err != nil {
t.Fatal(err)
}
// Open прогоняет оставшиеся миграции (0006).
st, err := Open(dbPath)
if err != nil {
t.Fatalf("open with migration: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
ctx := context.Background()
all, err := st.ListDownloads(ctx) // ORDER BY id DESC
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Fatalf("downloads = %d, want 3", len(all))
}
// Хронология сохранена: DESC по id = [Three, Two, Old One] (при равных
// секундах #2/#3 порядок старых id держит monotonic-энтропия).
if all[0].DisplayName != "Three" || all[1].DisplayName != "Two" || all[2].DisplayName != "Old One" {
t.Fatalf("порядок по id разъехался с хронологией: %s, %s, %s",
all[0].DisplayName, all[1].DisplayName, all[2].DisplayName)
}
one, two, three := all[2], all[1], all[0]
// Хеши разнесены с верным kind; created_at/updated_at сохранены.
if len(one.Infohashes) != 1 || one.Infohashes[0].Infohash != v1hash || one.Infohashes[0].Kind != HashV1 {
t.Fatalf("хеши #1 = %+v", one.Infohashes)
}
if len(two.Infohashes) != 1 || two.Infohashes[0].Infohash != v2hash || two.Infohashes[0].Kind != HashV2 {
t.Fatalf("хеши #2 = %+v", two.Infohashes)
}
if len(three.Infohashes) != 0 {
t.Fatalf("хеши #3 = %+v, want пусто", three.Infohashes)
}
if one.CreatedAt != "2026-01-01 10:00:00" || one.UpdatedAt != "2026-01-01 11:00:00" {
t.Fatalf("метки #1 = %q / %q", one.CreatedAt, one.UpdatedAt)
}
// FK-связи: распознавание/кандидаты/подсказки/правки/ссылки указывают на #1.
rec, err := st.GetCurrentRecognition(ctx, one.ID)
if err != nil || rec == nil || rec.Title.String != "Fargo" {
t.Fatalf("recognition #1 = %+v, %v", rec, err)
}
cands, err := st.ListCandidatesByRecognition(ctx, rec.ID)
if err != nil || len(cands) != 1 || !cands[0].Chosen {
t.Fatalf("candidates = %+v, %v", cands, err)
}
hints, err := st.ListHints(ctx, one.ID)
if err != nil || len(hints) != 1 || hints[0] != "второй сезон" {
t.Fatalf("hints = %v, %v", hints, err)
}
ovr, err := st.ListOverrides(ctx, one.ID)
if err != nil || ovr["media_type"] != "series" {
t.Fatalf("overrides = %v, %v", ovr, err)
}
batch, err := st.LatestBatchID(ctx, one.ID)
if err != nil || batch != "b-1" {
t.Fatalf("batch = %q, %v", batch, err)
}
// Активность выводится из state: v2-хеш занят активной #2.
active, err := st.FindActiveByInfohash(ctx, v2hash)
if err != nil || active == nil || active.ID != two.ID {
t.Fatalf("active by v2 = %+v, %v", active, err)
}
// Старые столбцы удалены.
var cols []string
if err := st.DB.Select(&cols, `SELECT name FROM pragma_table_info('download')`); err != nil {
t.Fatal(err)
}
for _, c := range cols {
if c == "infohash" || c == "idempotency_key" {
t.Fatalf("столбец %q должен быть удалён", c)
}
}
}
@@ -0,0 +1,439 @@
// Package migrations содержит Go-миграции goose (SQL-миграции лежат рядом
// *.sql-файлами и прогоняются из embed FS пакета store). Регистрация — в
// init(); чтобы она сработала, пакет blank-импортируется из store.
package migrations
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/pressly/goose/v3"
"git.vakhrushev.me/av/jellybit/internal/ident"
)
func init() {
goose.AddMigrationContext(upUlidIdentity, downUlidIdentity)
}
// upUlidIdentity переводит все таблицы на ULID-идентификаторы (TEXT PK),
// разносит download.infohash в download_infohash и убирает idempotency_key
// (см. openspec/changes/ulid-identity/design.md, D6).
//
// Работает при включённых foreign_keys (PRAGMA внутри транзакции — no-op),
// поэтому порядок жёсткий: новые таблицы и данные — родители первыми, DROP
// старых — дети первыми, затем RENAME (SQLite ≥ 3.25 переписывает REFERENCES
// в ссылающихся таблицах).
func upUlidIdentity(ctx context.Context, tx *sql.Tx) error {
if err := createNewTables(ctx, tx); err != nil {
return err
}
downloadIDs, err := migrateDownloads(ctx, tx)
if err != nil {
return err
}
recognitionIDs, err := migrateRecognitions(ctx, tx, downloadIDs)
if err != nil {
return err
}
if err := migrateHints(ctx, tx, downloadIDs); err != nil {
return err
}
if err := migrateOverrides(ctx, tx, downloadIDs); err != nil {
return err
}
if err := migrateCandidates(ctx, tx, recognitionIDs); err != nil {
return err
}
if err := migrateFileLinks(ctx, tx, downloadIDs); err != nil {
return err
}
// Старые таблицы: дети первыми, родитель последним (FK включены).
for _, stmt := range []string{
`DROP TABLE file_link`,
`DROP TABLE metadata_candidate`,
`DROP TABLE override`,
`DROP TABLE hint`,
`DROP TABLE recognition`,
`DROP TABLE download`,
`ALTER TABLE download_new RENAME TO download`,
`ALTER TABLE recognition_new RENAME TO recognition`,
`ALTER TABLE hint_new RENAME TO hint`,
`ALTER TABLE override_new RENAME TO override`,
`ALTER TABLE metadata_candidate_new RENAME TO metadata_candidate`,
`ALTER TABLE file_link_new RENAME TO file_link`,
// Индексы — после переименований, с каноническими именами (старые
// одноимённые ушли вместе со старыми таблицами).
`CREATE INDEX idx_download_state ON download (state)`,
`CREATE INDEX idx_download_infohash_download ON download_infohash (download_id)`,
`CREATE INDEX idx_recognition_download ON recognition (download_id)`,
`CREATE INDEX idx_hint_download ON hint (download_id)`,
`CREATE INDEX idx_candidate_recognition ON metadata_candidate (recognition_id)`,
`CREATE INDEX idx_file_link_download ON file_link (download_id)`,
`CREATE INDEX idx_file_link_batch ON file_link (apply_batch_id)`,
} {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("ulid migration: %q: %w", stmt, err)
}
}
return checkForeignKeys(ctx, tx)
}
// downUlidIdentity: обратной миграции нет — ULID → числовые id невосстановимы.
// Откат — восстановление файла БД из копии (см. design.md, Migration Plan).
func downUlidIdentity(context.Context, *sql.Tx) error {
return fmt.Errorf("ulid identity migration is irreversible; restore the database file from a backup")
}
func createNewTables(ctx context.Context, tx *sql.Tx) error {
for _, stmt := range []string{
`CREATE TABLE download_new (
id TEXT PRIMARY KEY,
source_type TEXT NOT NULL,
source_ref TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
context TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL,
error_code TEXT,
error_msg TEXT,
source_miss_count INTEGER NOT NULL DEFAULT 0,
source_added_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE download_infohash (
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
infohash TEXT NOT NULL,
kind TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (infohash, download_id)
)`,
`CREATE TABLE recognition_new (
id TEXT PRIMARY KEY,
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
attempt_no INTEGER NOT NULL DEFAULT 1,
is_current INTEGER NOT NULL DEFAULT 1,
media_type TEXT,
title TEXT,
original_title TEXT,
year INTEGER,
provider TEXT,
provider_id TEXT,
confidence REAL,
reasons TEXT NOT NULL DEFAULT '[]',
raw_llm TEXT,
plan TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE hint_new (
id TEXT PRIMARY KEY,
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE override_new (
id TEXT PRIMARY KEY,
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
field TEXT NOT NULL,
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (download_id, field)
)`,
`CREATE TABLE metadata_candidate_new (
id TEXT PRIMARY KEY,
recognition_id TEXT NOT NULL REFERENCES recognition_new (id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_id TEXT NOT NULL,
title TEXT,
year INTEGER,
chosen INTEGER NOT NULL DEFAULT 0,
url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE file_link_new (
id TEXT PRIMARY KEY,
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
apply_batch_id TEXT NOT NULL,
src_path TEXT NOT NULL,
dst_path TEXT NOT NULL,
kind TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
} {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("ulid migration: create tables: %w", err)
}
}
return nil
}
// idMap строит маппинг «старый int id → ULID» для таблицы: строки читаются в
// порядке старого id (хронология), timestamp-часть ULID — из created_at, так
// что лексикографический порядок новых id сохраняет исторический. Равные
// секунды created_at упорядочивает monotonic-энтропия по порядку обхода.
func idMap(ctx context.Context, tx *sql.Tx, table string) (map[int64]string, error) {
rows, err := tx.QueryContext(ctx,
`SELECT id, created_at FROM `+table+` ORDER BY id`) //nolint:gosec // имена таблиц — константы этого файла
if err != nil {
return nil, fmt.Errorf("ulid migration: read %s ids: %w", table, err)
}
defer func() { _ = rows.Close() }()
out := map[int64]string{}
for rows.Next() {
var id int64
var createdAt string
if err := rows.Scan(&id, &createdAt); err != nil {
return nil, fmt.Errorf("ulid migration: scan %s id: %w", table, err)
}
out[id] = ident.NewIDAt(parseCreatedAt(createdAt))
}
return out, rows.Err()
}
// parseCreatedAt разбирает метку datetime('now') (UTC); непарсибельная метка
// → текущее время (порядок в пределах таблицы всё равно монотонен).
func parseCreatedAt(s string) time.Time {
t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.UTC)
if err != nil {
return time.Now()
}
return t
}
func migrateDownloads(ctx context.Context, tx *sql.Tx) (map[int64]string, error) {
ids, err := idMap(ctx, tx, "download")
if err != nil {
return nil, err
}
// Сначала вычитываем всё и закрываем курсор, потом вставляем: Tx держит
// одно соединение, Exec при открытых Rows на нём невозможен.
type downloadRow struct {
id int64
sourceType, sourceRef, displayName string
contextText, state string
infohash, errorCode, errorMsg sql.NullString
sourceMissCount int
sourceAddedAt sql.NullString
createdAt, updatedAt string
}
var all []downloadRow
rows, err := tx.QueryContext(ctx, `
SELECT id, source_type, source_ref, display_name, context, infohash, state,
error_code, error_msg, source_miss_count, source_added_at,
created_at, updated_at
FROM download ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("ulid migration: read downloads: %w", err)
}
for rows.Next() {
var r downloadRow
if err := rows.Scan(&r.id, &r.sourceType, &r.sourceRef, &r.displayName,
&r.contextText, &r.infohash, &r.state, &r.errorCode, &r.errorMsg,
&r.sourceMissCount, &r.sourceAddedAt, &r.createdAt, &r.updatedAt); err != nil {
_ = rows.Close()
return nil, fmt.Errorf("ulid migration: scan download: %w", err)
}
all = append(all, r)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, fmt.Errorf("ulid migration: iterate downloads: %w", err)
}
_ = rows.Close()
for _, r := range all {
if _, err := tx.ExecContext(ctx, `
INSERT INTO download_new (id, source_type, source_ref, display_name, context,
state, error_code, error_msg, source_miss_count,
source_added_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
ids[r.id], r.sourceType, r.sourceRef, r.displayName, r.contextText,
r.state, r.errorCode, r.errorMsg, r.sourceMissCount, r.sourceAddedAt,
r.createdAt, r.updatedAt); err != nil {
return nil, fmt.Errorf("ulid migration: insert download %d: %w", r.id, err)
}
if r.infohash.Valid && r.infohash.String != "" {
h := strings.ToLower(r.infohash.String)
if _, err := tx.ExecContext(ctx, `
INSERT INTO download_infohash (download_id, infohash, kind, created_at)
VALUES (?, ?, ?, ?)`,
ids[r.id], h, hashKind(h), r.createdAt); err != nil {
return nil, fmt.Errorf("ulid migration: insert infohash for %d: %w", r.id, err)
}
}
}
return ids, nil
}
// hashKind — вид инфохэша по длине hex: 40 — v1 (SHA-1), 64 — v2 (SHA-256).
func hashKind(h string) string {
if len(h) == 64 {
return "v2"
}
return "v1"
}
func migrateRecognitions(ctx context.Context, tx *sql.Tx, downloads map[int64]string) (map[int64]string, error) {
ids, err := idMap(ctx, tx, "recognition")
if err != nil {
return nil, err
}
if err := copyRows(ctx, tx, copySpec{
from: "recognition", to: "recognition_new",
cols: []string{"attempt_no", "is_current", "media_type", "title", "original_title", "year", "provider", "provider_id", "confidence", "reasons", "raw_llm", "plan", "created_at"},
ids: ids,
parent: parentRef{col: "download_id", ids: downloads},
}); err != nil {
return nil, err
}
return ids, nil
}
func migrateHints(ctx context.Context, tx *sql.Tx, downloads map[int64]string) error {
ids, err := idMap(ctx, tx, "hint")
if err != nil {
return err
}
return copyRows(ctx, tx, copySpec{
from: "hint", to: "hint_new",
cols: []string{"text", "created_at"},
ids: ids,
parent: parentRef{col: "download_id", ids: downloads},
})
}
func migrateOverrides(ctx context.Context, tx *sql.Tx, downloads map[int64]string) error {
ids, err := idMap(ctx, tx, "override")
if err != nil {
return err
}
return copyRows(ctx, tx, copySpec{
from: "override", to: "override_new",
cols: []string{"field", "value", "created_at"},
ids: ids,
parent: parentRef{col: "download_id", ids: downloads},
})
}
func migrateCandidates(ctx context.Context, tx *sql.Tx, recognitions map[int64]string) error {
ids, err := idMap(ctx, tx, "metadata_candidate")
if err != nil {
return err
}
return copyRows(ctx, tx, copySpec{
from: "metadata_candidate", to: "metadata_candidate_new",
cols: []string{"provider", "provider_id", "title", "year", "chosen", "url", "created_at"},
ids: ids,
parent: parentRef{col: "recognition_id", ids: recognitions},
})
}
func migrateFileLinks(ctx context.Context, tx *sql.Tx, downloads map[int64]string) error {
ids, err := idMap(ctx, tx, "file_link")
if err != nil {
return err
}
return copyRows(ctx, tx, copySpec{
from: "file_link", to: "file_link_new",
cols: []string{"apply_batch_id", "src_path", "dst_path", "kind", "status", "created_at"},
ids: ids,
parent: parentRef{col: "download_id", ids: downloads},
})
}
// copySpec описывает перенос таблицы: собственный маппинг id, FK-родитель и
// прочие столбцы, копируемые как есть.
type copySpec struct {
from, to string
cols []string
ids map[int64]string
parent parentRef
}
type parentRef struct {
col string
ids map[int64]string
}
func copyRows(ctx context.Context, tx *sql.Tx, spec copySpec) error {
colList := strings.Join(spec.cols, ", ")
// Сначала вычитываем всё и закрываем курсор, потом вставляем (Exec при
// открытых Rows на соединении транзакции невозможен).
type rowData struct {
oldID, parentID int64
rest []any
}
var all []rowData
//nolint:gosec // имена таблиц/столбцов — константы этого файла
rows, err := tx.QueryContext(ctx, fmt.Sprintf(
`SELECT id, %s, %s FROM %s ORDER BY id`, spec.parent.col, colList, spec.from))
if err != nil {
return fmt.Errorf("ulid migration: read %s: %w", spec.from, err)
}
for rows.Next() {
r := rowData{rest: make([]any, len(spec.cols))}
dest := append([]any{&r.oldID, &r.parentID}, scanPtrs(r.rest)...)
if err := rows.Scan(dest...); err != nil {
_ = rows.Close()
return fmt.Errorf("ulid migration: scan %s: %w", spec.from, err)
}
all = append(all, r)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return fmt.Errorf("ulid migration: iterate %s: %w", spec.from, err)
}
_ = rows.Close()
ph := strings.TrimSuffix(strings.Repeat("?, ", len(spec.cols)), ", ")
//nolint:gosec // имена таблиц/столбцов — константы этого файла
insert := fmt.Sprintf(`INSERT INTO %s (id, %s, %s) VALUES (?, ?, %s)`,
spec.to, spec.parent.col, colList, ph)
for _, r := range all {
newParent, ok := spec.parent.ids[r.parentID]
if !ok {
return fmt.Errorf("ulid migration: %s row %d references unknown %s %d",
spec.from, r.oldID, spec.parent.col, r.parentID)
}
args := append([]any{spec.ids[r.oldID], newParent}, r.rest...)
if _, err := tx.ExecContext(ctx, insert, args...); err != nil {
return fmt.Errorf("ulid migration: insert %s row %d: %w", spec.to, r.oldID, err)
}
}
return nil
}
// scanPtrs — указатели на элементы среза для rows.Scan (значения любого типа
// SQLite едут через any и вставляются обратно как есть).
func scanPtrs(vals []any) []any {
out := make([]any, len(vals))
for i := range vals {
out[i] = &vals[i]
}
return out
}
// checkForeignKeys — финальная самопроверка целостности после rebuild.
func checkForeignKeys(ctx context.Context, tx *sql.Tx) error {
rows, err := tx.QueryContext(ctx, `PRAGMA foreign_key_check`)
if err != nil {
return fmt.Errorf("ulid migration: foreign_key_check: %w", err)
}
defer func() { _ = rows.Close() }()
if rows.Next() {
var table string
var rowid, parent, fkid any
_ = rows.Scan(&table, &rowid, &parent, &fkid)
return fmt.Errorf("ulid migration: foreign key violation in %s after rebuild", table)
}
return rows.Err()
}
+43 -44
View File
@@ -7,12 +7,14 @@ import (
"errors"
"fmt"
"strings"
"git.vakhrushev.me/av/jellybit/internal/ident"
)
// Recognition — строка таблицы recognition (попытка распознавания).
type Recognition struct {
ID int64 `db:"id"`
DownloadID int64 `db:"download_id"`
ID string `db:"id"`
DownloadID string `db:"download_id"`
AttemptNo int `db:"attempt_no"`
IsCurrent bool `db:"is_current"`
MediaType sql.NullString `db:"media_type"`
@@ -41,54 +43,50 @@ func (r Recognition) ReasonList() []string {
// CreateRecognition вставляет новую попытку распознавания, помечая прежние
// как неактуальные (is_current = 0) и проставляя следующий attempt_no.
// Возвращает id новой записи. reasons сериализуется в JSON.
func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons []string) (int64, error) {
func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons []string) (string, error) {
reasonsJSON, err := json.Marshal(reasons)
if err != nil {
return 0, fmt.Errorf("marshal reasons: %w", err)
return "", fmt.Errorf("marshal reasons: %w", err)
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("begin tx: %w", err)
return "", fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`UPDATE recognition SET is_current = 0 WHERE download_id = ?`, r.DownloadID); err != nil {
return 0, fmt.Errorf("clear current recognitions: %w", err)
return "", fmt.Errorf("clear current recognitions: %w", err)
}
var nextAttempt int
if err := tx.GetContext(ctx, &nextAttempt,
`SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM recognition WHERE download_id = ?`,
r.DownloadID); err != nil {
return 0, fmt.Errorf("next attempt_no: %w", err)
return "", fmt.Errorf("next attempt_no: %w", err)
}
r.ID = ident.NewID()
const q = `
INSERT INTO recognition
(download_id, attempt_no, is_current, media_type, title, original_title,
(id, download_id, attempt_no, is_current, media_type, title, original_title,
year, provider, provider_id, confidence, reasons, raw_llm, plan)
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
res, err := tx.ExecContext(ctx, q,
r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan)
if err != nil {
return 0, fmt.Errorf("insert recognition: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("recognition last insert id: %w", err)
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if _, err := tx.ExecContext(ctx, q,
r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan); err != nil {
return "", fmt.Errorf("insert recognition: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit recognition: %w", err)
return "", fmt.Errorf("commit recognition: %w", err)
}
return id, nil
return r.ID, nil
}
// GetCurrentRecognition возвращает актуальную попытку распознавания загрузки
// либо (nil, nil), если её ещё нет.
func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID int64) (*Recognition, error) {
func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID string) (*Recognition, error) {
var r Recognition
err := s.DB.GetContext(ctx, &r,
`SELECT * FROM recognition WHERE download_id = ? AND is_current = 1
@@ -105,16 +103,17 @@ func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID int64) (*R
// --- Подсказки (hint) ---
// AddHint добавляет текстовую подсказку ревьюера к загрузке.
func (s *Store) AddHint(ctx context.Context, downloadID int64, text string) error {
func (s *Store) AddHint(ctx context.Context, downloadID string, text string) error {
if _, err := s.DB.ExecContext(ctx,
`INSERT INTO hint (download_id, text) VALUES (?, ?)`, downloadID, text); err != nil {
`INSERT INTO hint (id, download_id, text) VALUES (?, ?, ?)`,
ident.NewID(), downloadID, text); err != nil {
return fmt.Errorf("add hint: %w", err)
}
return nil
}
// ListHints возвращает подсказки загрузки в хронологическом порядке.
func (s *Store) ListHints(ctx context.Context, downloadID int64) ([]string, error) {
func (s *Store) ListHints(ctx context.Context, downloadID string) ([]string, error) {
var out []string
if err := s.DB.SelectContext(ctx, &out,
`SELECT text FROM hint WHERE download_id = ? ORDER BY id`, downloadID); err != nil {
@@ -126,18 +125,18 @@ func (s *Store) ListHints(ctx context.Context, downloadID int64) ([]string, erro
// --- Ручные правки (override) ---
// SetOverride пиннит значение поля (upsert по (download_id, field)).
func (s *Store) SetOverride(ctx context.Context, downloadID int64, field, value string) error {
func (s *Store) SetOverride(ctx context.Context, downloadID string, field, value string) error {
const q = `
INSERT INTO override (download_id, field, value) VALUES (?, ?, ?)
INSERT INTO override (id, download_id, field, value) VALUES (?, ?, ?, ?)
ON CONFLICT (download_id, field) DO UPDATE SET value = excluded.value`
if _, err := s.DB.ExecContext(ctx, q, downloadID, field, value); err != nil {
if _, err := s.DB.ExecContext(ctx, q, ident.NewID(), downloadID, field, value); err != nil {
return fmt.Errorf("set override %q: %w", field, err)
}
return nil
}
// ListOverrides возвращает запиненные правки загрузки как map[field]value.
func (s *Store) ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error) {
func (s *Store) ListOverrides(ctx context.Context, downloadID string) (map[string]string, error) {
rows, err := s.DB.QueryxContext(ctx,
`SELECT field, value FROM override WHERE download_id = ?`, downloadID)
if err != nil {
@@ -160,8 +159,8 @@ func (s *Store) ListOverrides(ctx context.Context, downloadID int64) (map[string
// FileLink — строка таблицы file_link (одна созданная/планируемая ссылка).
type FileLink struct {
ID int64 `db:"id"`
DownloadID int64 `db:"download_id"`
ID string `db:"id"`
DownloadID string `db:"download_id"`
ApplyBatchID string `db:"apply_batch_id"`
SrcPath string `db:"src_path"`
DstPath string `db:"dst_path"`
@@ -182,11 +181,11 @@ func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error {
defer func() { _ = tx.Rollback() }()
const q = `
INSERT INTO file_link (download_id, apply_batch_id, src_path, dst_path, kind, status)
VALUES (?, ?, ?, ?, ?, ?)`
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`
for _, l := range links {
if _, err := tx.ExecContext(ctx, q,
l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status); err != nil {
ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status); err != nil {
return fmt.Errorf("insert file_link: %w", err)
}
}
@@ -202,7 +201,7 @@ VALUES (?, ?, ?, ?, ?, ?)`
// раскладка забирает владение освободившимся путём, прежние записи перестают
// считаться целью при сверке. Затрагивает только активные статусы раскладки
// (linked/copied/exists) и не трогает саму загрузку (download_id != ?).
func (s *Store) SupersedeForeignLinks(ctx context.Context, downloadID int64, dstPaths []string) error {
func (s *Store) SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error {
if len(dstPaths) == 0 {
return nil
}
@@ -226,7 +225,7 @@ WHERE download_id != ?
// LatestBatchID возвращает apply_batch_id последнего применённого батча
// загрузки (для undo) либо пустую строку, если ссылок нет.
func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (string, error) {
func (s *Store) LatestBatchID(ctx context.Context, downloadID string) (string, error) {
var batch string
err := s.DB.GetContext(ctx, &batch,
`SELECT apply_batch_id FROM file_link WHERE download_id = ?
@@ -265,8 +264,8 @@ func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) erro
// хранят значения для тега Jellyfin (напр. TVMaze отдаёт внешний TVDB-id —
// см. recognize), а не обязательно нативный id провайдера поиска.
type MetadataCandidate struct {
ID int64 `db:"id"`
RecognitionID int64 `db:"recognition_id"`
ID string `db:"id"`
RecognitionID string `db:"recognition_id"`
Provider string `db:"provider"`
ProviderID string `db:"provider_id"`
Title sql.NullString `db:"title"`
@@ -288,11 +287,11 @@ func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate)
defer func() { _ = tx.Rollback() }()
const q = `
INSERT INTO metadata_candidate (recognition_id, provider, provider_id, title, year, url)
VALUES (?, ?, ?, ?, ?, ?)`
INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url)
VALUES (?, ?, ?, ?, ?, ?, ?)`
for _, c := range cands {
if _, err := tx.ExecContext(ctx, q,
c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL); err != nil {
ident.NewID(), c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL); err != nil {
return fmt.Errorf("insert candidate: %w", err)
}
}
@@ -303,7 +302,7 @@ VALUES (?, ?, ?, ?, ?, ?)`
}
// ListCandidatesByRecognition возвращает кандидатов попытки распознавания.
func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]MetadataCandidate, error) {
func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID string) ([]MetadataCandidate, error) {
var out []MetadataCandidate
if err := s.DB.SelectContext(ctx, &out,
`SELECT * FROM metadata_candidate WHERE recognition_id = ? ORDER BY id`, recognitionID); err != nil {
@@ -313,21 +312,21 @@ func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID i
}
// GetCandidate возвращает кандидата по id либо (nil, nil).
func (s *Store) GetCandidate(ctx context.Context, id int64) (*MetadataCandidate, error) {
func (s *Store) GetCandidate(ctx context.Context, id string) (*MetadataCandidate, error) {
var c MetadataCandidate
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get candidate %d: %w", id, err)
return nil, fmt.Errorf("get candidate %s: %w", id, err)
}
return &c, nil
}
// SetCandidateChosen помечает кандидата выбранным, снимая отметку с прочих в
// той же попытке распознавания.
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error {
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID string) error {
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
+6 -39
View File
@@ -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)
}
+8 -1
View File
@@ -10,6 +10,9 @@ import (
"github.com/jmoiron/sqlx"
"github.com/pressly/goose/v3"
_ "modernc.org/sqlite" // драйвер database/sql, имя "sqlite"
// Go-миграции goose (регистрируются в init(); SQL-миграции — embed ниже).
_ "git.vakhrushev.me/av/jellybit/internal/store/migrations"
)
//go:embed migrations/*.sql
@@ -29,8 +32,12 @@ func Open(dbPath string) (*Store, error) {
}
}
// _txlock=immediate: каждая явная транзакция открывается как BEGIN
// IMMEDIATE — write-транзакция с самого начала. На этом держатся
// guarded-методы инварианта «одна активная загрузка на infohash»
// (check-then-write без гонок: SQLite сериализует писателей).
dsn := fmt.Sprintf(
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
"file:%s?_txlock=immediate&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
dbPath,
)
db, err := sqlx.Connect("sqlite", dsn)
+31 -28
View File
@@ -4,12 +4,12 @@ import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
@@ -30,13 +30,13 @@ type Ingestor interface {
// Reviewer — операции ревью (worker.Worker).
type Reviewer interface {
ReviewData(ctx context.Context, id int64) (*worker.ReviewData, error)
Apply(ctx context.Context, id int64) error
Refine(ctx context.Context, id int64, hint string) error
SetType(ctx context.Context, id int64, mediaType string) error
Defer(ctx context.Context, id int64) error
Cancel(ctx context.Context, id int64) error
Retry(ctx context.Context, id int64) error
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
Apply(ctx context.Context, id string) error
Refine(ctx context.Context, id string, hint string) error
SetType(ctx context.Context, id string, mediaType string) error
Defer(ctx context.Context, id string) error
Cancel(ctx context.Context, id string) error
Retry(ctx context.Context, id string) error
}
// Config — параметры бота.
@@ -54,8 +54,8 @@ type Bot struct {
webBase string
log *slog.Logger
mu sync.Mutex // защищает pending
pending map[int64]int64 // chatID → downloadID, ждущий подсказку
mu sync.Mutex // защищает pending
pending map[int64]string // chatID → downloadID, ждущий подсказку
}
// New собирает бота поверх клиента Telegram.
@@ -71,7 +71,7 @@ func New(client teleAPI, ing Ingestor, rev Reviewer, cfg Config, log *slog.Logge
allowed: allowed,
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
log: log,
pending: map[int64]int64{},
pending: map[int64]string{},
}
}
@@ -129,7 +129,7 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
return
}
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+strconv.FormatInt(id, 10)+"…", nil)
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+id+"…", nil)
return
}
@@ -151,9 +151,9 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
return
}
msg := fmt.Sprintf("Принято #%d — %s.", res.DownloadID, res.State)
msg := fmt.Sprintf("Принято #%s — %s.", res.DownloadID, res.State)
if res.Deduplicated {
msg = fmt.Sprintf("Уже в работе #%d — %s.", res.DownloadID, res.State)
msg = fmt.Sprintf("Уже в работе #%s — %s.", res.DownloadID, res.State)
}
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil)
}
@@ -173,8 +173,10 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
}
action, id, val := parseCallback(cq.Data)
if id == 0 {
b.answer(cq.ID, "")
if id == "" {
// Пустой/невалидный id — в т.ч. старые числовые кнопки, оставшиеся в
// истории чата до перехода на ULID: отвечаем понятно, а не молчим.
b.answer(cq.ID, "Кнопка устарела — откройте задачу в вебе")
return
}
chatID := cq.Message.Chat.ID
@@ -201,7 +203,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
case "refine":
b.setPending(chatID, id)
b.answer(cq.ID, "Жду подсказку")
b.send(chatID, "Ответьте сообщением с подсказкой для #"+strconv.FormatInt(id, 10)+".", nil)
b.send(chatID, "Ответьте сообщением с подсказкой для #"+id+".", nil)
return
default:
b.answer(cq.ID, "")
@@ -218,7 +220,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
}
// refreshCard перечитывает задачу и обновляет карточку на месте.
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64) {
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id string) {
rd, err := b.reviewer.ReviewData(ctx, id)
if err != nil {
b.log.Warn("telegram refresh card failed", "download_id", id, "error", err)
@@ -239,7 +241,7 @@ func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64
// --- Notifier (worker.Notifier) ---
// Notify шлёт карточку подтверждения/готовности всем доверенным пользователям.
func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyEvent) {
func (b *Bot) Notify(ctx context.Context, downloadID string, event worker.NotifyEvent) {
rd, err := b.reviewer.ReviewData(ctx, downloadID)
if err != nil {
b.log.Warn("telegram notify review data", "download_id", downloadID, "error", err)
@@ -281,13 +283,13 @@ func (b *Bot) answer(callbackID, text string) {
}
}
func (b *Bot) setPending(chatID, id int64) {
func (b *Bot) setPending(chatID int64, id string) {
b.mu.Lock()
b.pending[chatID] = id
b.mu.Unlock()
}
func (b *Bot) takePending(chatID int64) (int64, bool) {
func (b *Bot) takePending(chatID int64) (string, bool) {
b.mu.Lock()
defer b.mu.Unlock()
id, ok := b.pending[chatID]
@@ -300,20 +302,21 @@ func (b *Bot) takePending(chatID int64) (int64, bool) {
// opErr — сообщение публичного канала Telegram по доменной ошибке: нейтральный
// текст + download_id для корреляции с логами (полная ошибка уже там, на
// доменной границе). Сырой err.Error() пользователю не показываем. Если id
// операции ещё нет (downloadID == 0) — дружелюбный текст без ключа.
func opErr(msg string, downloadID int64) string {
if downloadID > 0 {
return fmt.Sprintf("%s (download_id=%d).", msg, downloadID)
// операции ещё нет (downloadID == "") — дружелюбный текст без ключа.
func opErr(msg string, downloadID string) string {
if downloadID != "" {
return fmt.Sprintf("%s (download_id=%s).", msg, downloadID)
}
return msg + "."
}
// parseCallback разбирает "action[:id[:value]]".
func parseCallback(data string) (action string, id int64, value string) {
// parseCallback разбирает "action[:id[:value]]". id валидируется как ULID
// (входная граница); невалидный/устаревший (числовой) → пустая строка.
func parseCallback(data string) (action string, id string, value string) {
parts := strings.Split(data, ":")
action = parts[0]
if len(parts) > 1 {
id, _ = strconv.ParseInt(parts[1], 10, 64)
id, _ = ident.Parse(parts[1])
}
if len(parts) > 2 {
value = parts[2]
+59 -40
View File
@@ -60,52 +60,55 @@ func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Res
type fakeReviewer struct {
data *worker.ReviewData
applied []int64
refined map[int64]string
typed map[int64]string
deferred []int64
canceled []int64
retried []int64
applied []string
refined map[string]string
typed map[string]string
deferred []string
canceled []string
retried []string
}
func (f *fakeReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) {
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
return f.data, nil
}
func (f *fakeReviewer) Apply(_ context.Context, id int64) error {
func (f *fakeReviewer) Apply(_ context.Context, id string) error {
f.applied = append(f.applied, id)
return nil
}
func (f *fakeReviewer) Refine(_ context.Context, id int64, hint string) error {
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
if f.refined == nil {
f.refined = map[int64]string{}
f.refined = map[string]string{}
}
f.refined[id] = hint
return nil
}
func (f *fakeReviewer) SetType(_ context.Context, id int64, t string) error {
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
if f.typed == nil {
f.typed = map[int64]string{}
f.typed = map[string]string{}
}
f.typed[id] = t
return nil
}
func (f *fakeReviewer) Defer(_ context.Context, id int64) error {
func (f *fakeReviewer) Defer(_ context.Context, id string) error {
f.deferred = append(f.deferred, id)
return nil
}
func (f *fakeReviewer) Cancel(_ context.Context, id int64) error {
func (f *fakeReviewer) Cancel(_ context.Context, id string) error {
f.canceled = append(f.canceled, id)
return nil
}
func (f *fakeReviewer) Retry(_ context.Context, id int64) error {
func (f *fakeReviewer) Retry(_ context.Context, id string) error {
f.retried = append(f.retried, id)
return nil
}
// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
const tid = "01arz3ndektsv4rrffq69g5fav"
func reviewData(state store.State) *worker.ReviewData {
s, e := 2, 1
return &worker.ReviewData{
Download: store.Download{ID: 5, State: state, Context: "Фарго, второй сезон", SourceRef: "magnet:?x"},
Download: store.Download{ID: tid, State: state, Context: "Фарго, второй сезон", SourceRef: "magnet:?x"},
Recognition: &store.Recognition{
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
Reasons: `["неполный пак"]`,
@@ -123,7 +126,7 @@ func reviewData(state store.State) *worker.ReviewData {
func newTestBot(t *testing.T, allowed []int64) (*Bot, *fakeAPI, *fakeIngestor, *fakeReviewer) {
t.Helper()
api := &fakeAPI{}
ing := &fakeIngestor{res: ingest.Result{DownloadID: 5, State: store.StateDownloading}}
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateDownloading}}
rev := &fakeReviewer{data: reviewData(store.StateReview)}
b := New(api, ing, rev, Config{AllowedUserIDs: allowed, WebBaseURL: "http://host:8080"},
slog.New(slog.NewTextHandler(io.Discard, nil)))
@@ -146,7 +149,7 @@ func TestBot_IngestFromMagnet(t *testing.T) {
if ing.lastReq.Context != "крутой сериал" {
t.Errorf("context = %q", ing.lastReq.Context)
}
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #5") {
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+tid) {
t.Errorf("sent = %+v", api.sent)
}
}
@@ -174,10 +177,10 @@ func TestBot_NoMagnet(t *testing.T) {
func TestBot_RefineViaReply(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
// Кнопка «Уточнить» поставила ожидание подсказки для чата 7.
b.setPending(7, 5)
b.setPending(7, tid)
b.handleMessage(context.Background(), msgFrom(7, "это второй сезон"))
if rev.refined[5] != "это второй сезон" {
if rev.refined[tid] != "это второй сезон" {
t.Errorf("refine = %v", rev.refined)
}
}
@@ -191,9 +194,9 @@ func cbFrom(userID int64, data string) *tgbotapi.CallbackQuery {
func TestBot_CallbackApply(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "apply:5"))
b.handleCallback(context.Background(), cbFrom(7, "apply:"+tid))
if len(rev.applied) != 1 || rev.applied[0] != 5 {
if len(rev.applied) != 1 || rev.applied[0] != tid {
t.Errorf("applied = %v", rev.applied)
}
if len(api.answers) != 1 {
@@ -206,18 +209,18 @@ func TestBot_CallbackApply(t *testing.T) {
func TestBot_CallbackType(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "type:5:movie"))
if rev.typed[5] != "movie" {
b.handleCallback(context.Background(), cbFrom(7, "type:"+tid+":movie"))
if rev.typed[tid] != "movie" {
t.Errorf("typed = %v", rev.typed)
}
}
func TestBot_CallbackRefineSetsPending(t *testing.T) {
b, api, _, _ := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "refine:5"))
b.handleCallback(context.Background(), cbFrom(7, "refine:"+tid))
if id, ok := b.takePending(7); !ok || id != 5 {
t.Errorf("pending = %d,%v", id, ok)
if id, ok := b.takePending(7); !ok || id != tid {
t.Errorf("pending = %s,%v", id, ok)
}
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "подсказкой") {
t.Errorf("sent = %+v", api.sent)
@@ -226,7 +229,7 @@ func TestBot_CallbackRefineSetsPending(t *testing.T) {
func TestBot_CallbackDeniesUnknown(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(999, "apply:5"))
b.handleCallback(context.Background(), cbFrom(999, "apply:"+tid))
if len(rev.applied) != 0 {
t.Error("чужой колбэк не должен исполняться")
}
@@ -234,12 +237,12 @@ func TestBot_CallbackDeniesUnknown(t *testing.T) {
func TestBot_NotifyReview(t *testing.T) {
b, api, _, _ := newTestBot(t, []int64{7, 8})
b.Notify(context.Background(), 5, worker.EventReview)
b.Notify(context.Background(), tid, worker.EventReview)
if len(api.sent) != 2 { // обоим доверенным
t.Fatalf("sent to %d chats, want 2", len(api.sent))
}
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #5") {
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #"+tid) {
t.Errorf("card text = %q", api.sent[0].text)
}
if !api.sent[0].hasKB {
@@ -250,7 +253,7 @@ func TestBot_NotifyReview(t *testing.T) {
func TestBot_NotifyDone(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
rev.data = reviewData(store.StateDone)
b.Notify(context.Background(), 5, worker.EventDone)
b.Notify(context.Background(), tid, worker.EventDone)
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Готово") {
t.Errorf("sent = %+v", api.sent)
@@ -260,7 +263,7 @@ func TestBot_NotifyDone(t *testing.T) {
func TestBot_NotifyFailed(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
rev.data = reviewData(store.StateFailed)
b.Notify(context.Background(), 5, worker.EventFailed)
b.Notify(context.Background(), tid, worker.EventFailed)
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") {
t.Errorf("sent = %+v", api.sent)
@@ -273,20 +276,36 @@ func TestBot_NotifyFailed(t *testing.T) {
func TestBot_CallbackRetry(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
rev.data = reviewData(store.StateFailed)
b.handleCallback(context.Background(), cbFrom(7, "retry:5"))
b.handleCallback(context.Background(), cbFrom(7, "retry:"+tid))
if len(rev.retried) != 1 || rev.retried[0] != 5 {
if len(rev.retried) != 1 || rev.retried[0] != tid {
t.Errorf("retried = %v", rev.retried)
}
}
func TestParseCallback(t *testing.T) {
a, id, v := parseCallback("type:5:series")
if a != "type" || id != 5 || v != "series" {
t.Errorf("got %q %d %q", a, id, v)
a, id, v := parseCallback("type:" + tid + ":series")
if a != "type" || id != tid || v != "series" {
t.Errorf("got %q %q %q", a, id, v)
}
a, id, v = parseCallback("apply:9")
if a != "apply" || id != 9 || v != "" {
t.Errorf("got %q %d %q", a, id, v)
a, id, v = parseCallback("apply:" + tid)
if a != "apply" || id != tid || v != "" {
t.Errorf("got %q %q %q", a, id, v)
}
// Устаревшая числовая кнопка (до перехода на ULID) → id пуст.
if _, id, _ := parseCallback("apply:5"); id != "" {
t.Errorf("legacy numeric id must be rejected, got %q", id)
}
}
// Нажатие устаревшей кнопки со старым числовым id получает понятный ответ.
func TestBot_CallbackStaleButton(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "apply:5"))
if len(rev.applied) != 0 {
t.Error("устаревшая кнопка не должна исполняться")
}
if len(api.answers) != 1 || !strings.Contains(api.answers[0], "устарела") {
t.Errorf("answers = %v, want понятный ответ", api.answers)
}
}
+13 -16
View File
@@ -3,7 +3,6 @@ package tgbot
import (
"fmt"
"path/filepath"
"strconv"
"strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
@@ -21,13 +20,13 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
case store.StateReview, store.StateDeferred:
return b.reviewCard(rd)
case store.StateRecognizing:
return "⏳ Распознаю #" + itoa(id) + "…", b.webOnly(id)
return "⏳ Распознаю #" + id + "…", b.webOnly(id)
case store.StateLinking:
return "⏳ Раскладываю #" + itoa(id) + "…", nil
return "⏳ Раскладываю #" + id + "…", nil
case store.StateDone:
return b.renderDone(rd), b.webOnly(id)
default:
text := fmt.Sprintf("Задача #%d — %s.", id, state)
text := fmt.Sprintf("Задача #%s — %s.", id, state)
if msg := rd.Download.ErrorMsg.String; msg != "" {
text += "\n" + msg
}
@@ -43,7 +42,7 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
id := rd.Download.ID
var sb strings.Builder
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%d\n", id)
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%s\n", id)
if src := contextOrSource(rd); src != "" {
fmt.Fprintf(&sb, "Источник: %s\n", shorten(src, 80))
}
@@ -63,7 +62,7 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
id := rd.Download.ID
sid := itoa(id)
sid := id
var row1 []tgbotapi.InlineKeyboardButton
if len(rd.Preview) > 0 {
@@ -90,7 +89,7 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
func (b *Bot) renderDone(rd *worker.ReviewData) string {
title := rd.Plan.Title
if title == "" {
title = "#" + itoa(rd.Download.ID)
title = "#" + rd.Download.ID
}
n := len(rd.Preview)
if n == 0 {
@@ -103,7 +102,7 @@ func (b *Bot) renderDone(rd *worker.ReviewData) string {
func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string {
title := rd.Plan.Title
if title == "" {
title = "#" + itoa(rd.Download.ID)
title = "#" + rd.Download.ID
}
switch event {
case worker.EventTargetMissing:
@@ -123,7 +122,7 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
if rd.Download.State == store.StateStuck {
verb = "зависла"
}
fmt.Fprintf(&sb, "❌ Задача #%d %s", id, verb)
fmt.Fprintf(&sb, "❌ Задача #%s %s", id, verb)
if code := rd.Download.ErrorCode.String; code != "" {
fmt.Fprintf(&sb, " (%s)", code)
}
@@ -139,9 +138,9 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
}
// retryKeyboard — клавиатура для failed/stuck: повтор + опц. ссылка в веб.
func (b *Bot) retryKeyboard(id int64) *tgbotapi.InlineKeyboardMarkup {
func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
row := []tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+itoa(id)),
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+id),
}
if url := b.reviewURL(id); url != "" {
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
@@ -150,7 +149,7 @@ func (b *Bot) retryKeyboard(id int64) *tgbotapi.InlineKeyboardMarkup {
return &kb
}
func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
url := b.reviewURL(id)
if url == "" {
return nil
@@ -161,11 +160,11 @@ func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
return &kb
}
func (b *Bot) reviewURL(id int64) string {
func (b *Bot) reviewURL(id string) string {
if b.webBase == "" {
return ""
}
return b.webBase + "/review/" + itoa(id)
return b.webBase + "/review/" + id
}
// --- мелкие хелперы ---
@@ -233,5 +232,3 @@ func shorten(s string, n int) string {
}
return string(r[:n]) + "…"
}
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
+49 -31
View File
@@ -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
}
+63 -17
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
+22 -29
View File
@@ -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)
}
+21 -21
View File
@@ -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)
}
}
+23 -23
View File
@@ -14,13 +14,13 @@ import (
var addedRecent = time.Date(2026, 6, 14, 9, 59, 0, 0, time.UTC).Unix()
func oneFailed(state store.State, code, infohash, createdAt string) *fakeStore {
return &fakeStore{downloads: map[int64]*store.Download{
1: {
ID: 1,
return &fakeStore{downloads: map[string]*store.Download{
"1": {
ID: "1",
State: state,
SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash,
Infohash: store.NullString(infohash),
Infohashes: hashesOf("1", infohash),
ErrorCode: store.NullString(code),
CreatedAt: createdAt,
},
@@ -52,7 +52,7 @@ func TestRecovery(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := st.downloads[1].State; got != tc.want {
if got := st.downloads["1"].State; got != tc.want {
t.Errorf("state = %q, want %q", got, tc.want)
}
})
@@ -68,8 +68,8 @@ func TestRecoveryNoSourceStaysFailed(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateFailed {
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateFailed {
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads["1"].State)
}
}
@@ -78,11 +78,11 @@ func TestRecoveryNoSourceStaysFailed(t *testing.T) {
func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
st.downloads[2] = &store.Download{
ID: 2,
st.downloads["2"] = &store.Download{
ID: "2",
State: store.StateDownloading,
SourceType: store.SourceMagnet,
Infohash: store.NullString(ih),
Infohashes: hashesOf("2", ih),
CreatedAt: timeRecent,
}
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}}
@@ -90,8 +90,8 @@ func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads["1"].State)
}
}
@@ -101,11 +101,11 @@ func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
st.downloads[2] = &store.Download{
ID: 2,
st.downloads["2"] = &store.Download{
ID: "2",
State: store.StateDownloading,
SourceType: store.SourceMagnet,
Infohash: store.NullString(ih),
Infohashes: hashesOf("2", ih),
CreatedAt: timeRecent,
}
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "uploading", AddedOn: addedRecent}}}
@@ -113,8 +113,8 @@ func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads["1"].State)
}
}
@@ -126,7 +126,7 @@ func TestFailNotifyDebounce(t *testing.T) {
w := newTestWorker(st, &fakeQbt{})
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
w.SetNotifier(n)
d := *st.downloads[1]
d := *st.downloads["1"]
w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
if e := waitNotify(t, n); e.ev != EventFailed {
@@ -151,11 +151,11 @@ func TestRetryReattachesNoReadd(t *testing.T) {
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}}
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), 1); err != nil {
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
if st.downloads[1].State != store.StateDownloading {
t.Fatalf("после retry ожидался downloading, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateDownloading {
t.Fatalf("после retry ожидался downloading, got %q", st.downloads["1"].State)
}
if len(qb.added) != 0 {
t.Errorf("живой торрент не должен добавляться повторно, got %d Add", len(qb.added))
@@ -164,7 +164,7 @@ func TestRetryReattachesNoReadd(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateDownloading {
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateDownloading {
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads["1"].State)
}
}
+64 -60
View File
@@ -50,7 +50,7 @@ func (w *Worker) recognizePending(ctx context.Context) {
// под блокировкой переводим в recognizing, LLM зовём без блокировки, затем
// под блокировкой фиксируем результат — но только если задачу за это время
// не увели в другое состояние (cancel/defer).
func (w *Worker) recognizeOne(ctx context.Context, id int64) {
func (w *Worker) recognizeOne(ctx context.Context, id string) {
w.mu.Lock()
d, err := w.store.GetDownload(ctx, id)
if err != nil {
@@ -62,7 +62,7 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
w.mu.Unlock()
return
}
ctx = w.scoped(ctx, capRecognize, id, d.Infohash.String)
ctx = w.scoped(ctx, capRecognize, id, d.PrimaryInfohash())
if d.State == store.StateCompleted {
w.transition(ctx, *d, store.StateRecognizing, "", "")
}
@@ -84,10 +84,10 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
// затем зовёт распознаватель. Возвращает также savePath для маппинга
// относительных путей файлов в абсолютные при раскладке.
func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.Result, string, error) {
if !d.Infohash.Valid {
if len(d.Infohashes) == 0 {
return recognize.Result{}, "", fmt.Errorf("no infohash")
}
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
t, ok, err := w.torrentByInfohash(ctx, d.HashList())
if err != nil {
return recognize.Result{}, "", err
}
@@ -123,7 +123,7 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review.
func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.Result, savePath string) {
func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize.Result, savePath string) {
log := logctx.From(ctx)
planJSON, err := json.Marshal(res.Plan)
if err != nil {
@@ -174,6 +174,9 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
log.Error("recognition persist failed", "error", err)
return
}
// recognition_id — ключ корреляции попытки (грепается и голым ULID).
log.Info("recognition persisted", "recognition_id", recID,
"provider", provider, "provider_id", providerID)
// Кандидаты базы — для ручного выбора в review.
if cands := toStoreCandidates(recID, res.Candidates); len(cands) > 0 {
if err := w.store.CreateCandidates(ctx, cands); err != nil {
@@ -189,7 +192,7 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
forceReview := overrides[ovrForceReview] == "1"
if res.Decision.Auto && !forceReview && w.layouter != nil {
plan := applyOverrides(res.Plan, overrides)
lctx := w.scoped(ctx, capFileLayout, id, d.Infohash.String)
lctx := w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
w.transition(lctx, *d, store.StateLinking, "", "")
if err := w.linkPlan(lctx, d, plan, tag, savePath); err != nil {
logctx.From(lctx).Warn("auto-apply failed, left for review", "error", err)
@@ -200,7 +203,7 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
}
// overridesOrNil читает правки, проглатывая ошибку (для авто-пути).
func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string {
func (w *Worker) overridesOrNil(ctx context.Context, id string) map[string]string {
o, err := w.store.ListOverrides(ctx, id)
if err != nil {
logctx.From(ctx).Warn("recognition list overrides failed", "error", err)
@@ -213,7 +216,7 @@ func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string
// Apply создаёт хардлинки по текущему плану (с применёнными правками) и
// переводит задачу в done. Коллизия цели → остаёмся в review с причиной.
func (w *Worker) Apply(ctx context.Context, id int64) error {
func (w *Worker) Apply(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
@@ -225,15 +228,15 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
return fmt.Errorf("apply: %w", err)
}
if d.State != store.StateReview && d.State != store.StateDeferred {
return fmt.Errorf("apply: download %d is in state %s (expected review/deferred): %w", id, d.State, ErrConflict)
return fmt.Errorf("apply: download %s is in state %s (expected review/deferred): %w", id, d.State, ErrConflict)
}
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
plan, tag, err := w.effectivePlan(ctx, id)
if err != nil {
return fmt.Errorf("apply: %w", err)
}
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
t, ok, err := w.torrentByInfohash(ctx, d.HashList())
if err != nil {
return fmt.Errorf("apply: lookup torrent: %w", err)
}
@@ -308,7 +311,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
}
w.transition(ctx, *d, store.StateDone, "", "")
logctx.From(ctx).Info("layout linked", "batch", batch, "links", len(fl))
logctx.From(ctx).Info("layout linked", "batch_id", batch, "links", len(fl))
return nil
}
@@ -317,7 +320,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
// перепривязка всегда проходит через ревью с подтверждением (force_review).
// Источник (раздача в qBittorrent) для этого должен быть на месте.
func (w *Worker) Relink(ctx context.Context, id int64) error {
func (w *Worker) Relink(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -326,28 +329,26 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
return fmt.Errorf("relink: %w", err)
}
if d.State != store.StateReverted && d.State != store.StateCancelled && d.State != store.StateTargetMissing {
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
return fmt.Errorf("relink: download %s is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
}
// Источник нужен для распознавания — проверяем синхронно (без дебаунса) и при
// его отсутствии приводим состояние к реальности (orphaned/deleted).
if err := w.ensureSourcePresent(ctx, d, "relink"); err != nil {
return err
}
// Вернуть задачу в активную обработку можно, только если другой активной
// задачи на этот infohash нет (partial unique index по idempotency_key).
active, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
if err != nil {
return fmt.Errorf("relink: %w", err)
}
if active != nil {
return fmt.Errorf("relink: для этого торрента уже есть активная задача #%d", active.ID)
}
// Ручная перепривязка — всегда с подтверждением, без авто-раскладки.
if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil {
return fmt.Errorf("relink: %w", err)
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
w.transition(ctx, *d, store.StateRecognizing, "", "")
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
// Возврат в активную обработку — только через атомарный гард инварианта
// «не более одной активной задачи на infohash» (см. design ulid-identity, D4).
if err := w.store.ActivateIfNoOtherActive(ctx, id, store.StateRecognizing, "", ""); err != nil {
if errors.Is(err, store.ErrInfohashTaken) {
return fmt.Errorf("relink: для этого торрента уже есть активная задача: %w", ErrConflict)
}
return fmt.Errorf("relink: %w", err)
}
logctx.From(ctx).Info("relink re-recognizing", "from", d.State)
return nil
}
@@ -355,7 +356,7 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
// Rerecognize перезапускает распознавание для задачи в review/deferred без
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
// цикл проведёт задачу recognizing → review заново.
func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
func (w *Worker) Rerecognize(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -366,14 +367,14 @@ func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
if err := w.ensureSourcePresent(ctx, d, "rerecognize"); err != nil {
return err
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
logctx.From(ctx).Info("review re-recognizing without hint")
w.transition(ctx, *d, store.StateRecognizing, "", "")
return nil
}
// Refine добавляет подсказку и отправляет задачу на перераспознавание.
func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
func (w *Worker) Refine(ctx context.Context, id string, hint string) error {
hint = strings.TrimSpace(hint)
if hint == "" {
return fmt.Errorf("refine: empty hint")
@@ -388,7 +389,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
if err := w.ensureSourcePresent(ctx, d, "refine"); err != nil {
return err
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
if err := w.store.AddHint(ctx, id, hint); err != nil {
return fmt.Errorf("refine: %w", err)
}
@@ -399,7 +400,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
// SetType фиксирует тип (override) и перезапускает распознавание с подсказкой
// — чтобы LLM пересобрал роли файлов под новый тип.
func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error {
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) error {
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
return fmt.Errorf("set type: invalid type %q", mediaType)
}
@@ -413,7 +414,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
if err := w.ensureSourcePresent(ctx, d, "set type"); err != nil {
return err
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil {
return fmt.Errorf("set type: %w", err)
}
@@ -430,7 +431,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
// IgnoreFile помечает файл к игнорированию (не линкуем). Остаёмся в review;
// превью пересчитается с учётом правки.
func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) error {
src = strings.TrimSpace(src)
if src == "" {
return fmt.Errorf("ignore: empty path")
@@ -454,12 +455,12 @@ func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
if err := w.store.SetOverride(ctx, id, ovrIgnoredFiles, string(b)); err != nil {
return fmt.Errorf("ignore: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review file ignored", "src", src)
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review file ignored", "src", src)
return nil
}
// Defer паркует задачу в deferred (вернётся в ревью по действию).
func (w *Worker) Defer(ctx context.Context, id int64) error {
func (w *Worker) Defer(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -468,9 +469,9 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
return fmt.Errorf("defer: %w", err)
}
if d.State.IsTerminal() {
return fmt.Errorf("defer: download %d is terminal (%s)", id, d.State)
return fmt.Errorf("defer: download %s is terminal (%s)", id, d.State)
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
w.transition(ctx, *d, store.StateDeferred, "", "")
return nil
}
@@ -479,7 +480,7 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
// Источник недосягаем (раскладчик удаляет только пути под библиотекой). Откат
// снимает ЛИШНИЙ хардлинк, а не последнюю копию: layout.Undo отказывается
// удалять ссылку, если источник уже пропал (nlink<=1) — см. state-reconciliation.
func (w *Worker) Undo(ctx context.Context, id int64) error {
func (w *Worker) Undo(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
@@ -496,9 +497,9 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
return fmt.Errorf("undo: источник удалён, цель — последняя копия данных, откат невозможен: %w", ErrConflict)
}
if d.State != store.StateDone {
return fmt.Errorf("undo: download %d is in state %s (expected done): %w", id, d.State, ErrConflict)
return fmt.Errorf("undo: download %s is in state %s (expected done): %w", id, d.State, ErrConflict)
}
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
batch, err := w.store.LatestBatchID(ctx, id)
if err != nil {
return fmt.Errorf("undo: %w", err)
@@ -528,18 +529,18 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
return fmt.Errorf("undo: %w", err)
}
w.transition(ctx, *d, store.StateReverted, "", "")
logctx.From(ctx).Info("layout reverted", "batch", batch, "removed", n)
logctx.From(ctx).Info("layout reverted", "batch_id", batch, "removed", n)
return nil
}
// requireReviewable проверяет, что задача в review/deferred. Вызывается под mu.
func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*store.Download, error) {
func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*store.Download, error) {
d, err := w.store.GetDownload(ctx, id)
if err != nil {
return nil, fmt.Errorf("%s: %w", op, err)
}
if d.State != store.StateReview && d.State != store.StateDeferred {
return nil, fmt.Errorf("%s: download %d is in state %s (expected review/deferred): %w", op, id, d.State, ErrConflict)
return nil, fmt.Errorf("%s: download %s is in state %s (expected review/deferred): %w", op, id, d.State, ErrConflict)
}
return d, nil
}
@@ -549,7 +550,7 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
// id, каноническое имя/год). Раскладку не запускает — превью обновится, а
// человек подтвердит «Применить».
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) error {
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -566,7 +567,7 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) err
return fmt.Errorf("choose candidate: %w", err)
}
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
return fmt.Errorf("choose candidate: candidate %d does not belong to the current recognition", candidateID)
return fmt.Errorf("choose candidate: candidate %s does not belong to the current recognition", candidateID)
}
pins := map[string]string{ovrProvider: cand.Provider, ovrProviderID: cand.ProviderID}
@@ -584,13 +585,13 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) err
if err := w.store.SetCandidateChosen(ctx, rec.ID, candidateID); err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review candidate chosen",
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
"provider", cand.Provider, "provider_id", cand.ProviderID)
return nil
}
// SetProviderID пиннит провайдера и id вручную (без выбора из списка).
func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, providerID string) error {
func (w *Worker) SetProviderID(ctx context.Context, id string, provider, providerID string) error {
provider = strings.TrimSpace(strings.ToLower(provider))
providerID = strings.TrimSpace(providerID)
switch provider {
@@ -614,13 +615,13 @@ func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, provider
if err := w.store.SetOverride(ctx, id, ovrProviderID, providerID); err != nil {
return fmt.Errorf("set provider: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider set",
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider set",
"provider", provider, "provider_id", providerID)
return nil
}
// ClearProvider — «без базы»: снимает матч (тег папки не ставится).
func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
func (w *Worker) ClearProvider(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -634,7 +635,7 @@ func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
if err := w.store.SetOverride(ctx, id, ovrProviderID, ""); err != nil {
return fmt.Errorf("clear provider: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider cleared")
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider cleared")
return nil
}
@@ -654,12 +655,12 @@ type ReviewData struct {
}
// ReviewData собирает данные ревью по загрузке.
func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error) {
func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error) {
d, err := w.store.GetDownload(ctx, id)
if err != nil {
return nil, fmt.Errorf("review data: %w", err)
}
log := logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String))
log := logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash()))
rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil {
return nil, fmt.Errorf("review data: %w", err)
@@ -709,7 +710,7 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
// effectivePlan загружает текущий план, применяет правки и возвращает
// provider-тег для имени папки (под mu).
func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, string, error) {
func (w *Worker) effectivePlan(ctx context.Context, id string) (recognize.Plan, string, error) {
rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil {
return recognize.Plan{}, "", err
@@ -772,7 +773,7 @@ func effectiveProvider(rec *store.Recognition, overrides map[string]string) (pro
// toStoreCandidates переводит кандидатов распознавания в строки БД,
// подставляя тег-предпочтительный provider/id (внешний из TVMaze и т.п.).
func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store.MetadataCandidate {
func toStoreCandidates(recognitionID string, cands []metadata.Candidate) []store.MetadataCandidate {
out := make([]store.MetadataCandidate, 0, len(cands))
for _, c := range cands {
prov, id := recognize.CandidateTag(c)
@@ -863,19 +864,22 @@ func mapRole(r recognize.FileRole) (layout.Role, bool) {
}
}
// torrentByInfohash ищет торрент по infohash (v1/v2/hash). Листаем ВСЕ
// торренты (а не только свою категорию): раздача могла быть усыновлена по
// тегу и иметь чужую/пустую категорию — фильтр по категории её бы потерял
// (как и в Poll, см. там же).
func (w *Worker) torrentByInfohash(ctx context.Context, infohash string) (qbt.Torrent, bool, error) {
// torrentByInfohash ищет торрент по любому из хешей загрузки (v1/v2/hash).
// Листаем ВСЕ торренты (а не только свою категорию): раздача могла быть
// усыновлена по тегу и иметь чужую/пустую категорию — фильтр по категории её
// бы потерял (как и в Poll, см. там же).
func (w *Worker) torrentByInfohash(ctx context.Context, hashes []string) (qbt.Torrent, bool, error) {
torrents, err := w.qbt.Torrents(ctx, "")
if err != nil {
return qbt.Torrent{}, false, err
}
want := strings.ToLower(infohash)
want := make(map[string]bool, len(hashes))
for _, h := range hashes {
want[store.NormalizeHash(h)] = true
}
for _, t := range torrents {
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
if h != "" && strings.ToLower(h) == want {
if h != "" && want[strings.ToLower(h)] {
return t, true, nil
}
}
+204 -168
View File
@@ -20,12 +20,12 @@ import (
// recordingNotifier ловит события пинга (Notify асинхронен — через канал).
type notifyEvent struct {
id int64
id string
ev NotifyEvent
}
type recordingNotifier struct{ ch chan notifyEvent }
func (n *recordingNotifier) Notify(_ context.Context, id int64, ev NotifyEvent) {
func (n *recordingNotifier) Notify(_ context.Context, id string, ev NotifyEvent) {
n.ch <- notifyEvent{id, ev}
}
@@ -42,7 +42,7 @@ func waitNotify(t *testing.T, n *recordingNotifier) notifyEvent {
func TestNotifier_FiresOnReview(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1))
st.put(completedDownload("1"))
qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}},
@@ -51,10 +51,10 @@ func TestNotifier_FiresOnReview(t *testing.T) {
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
w.SetNotifier(n)
w.recognizeOne(context.Background(), 1)
w.recognizeOne(context.Background(), "1")
e := waitNotify(t, n)
if e.id != 1 || e.ev != EventReview {
if e.id != "1" || e.ev != EventReview {
t.Errorf("event = %+v, want {1 review}", e)
}
}
@@ -64,11 +64,11 @@ func TestNotifier_FiresOnDone(t *testing.T) {
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
f.w.SetNotifier(n)
if err := f.w.Apply(context.Background(), 1); err != nil {
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
e := waitNotify(t, n)
if e.id != 1 || e.ev != EventDone {
if e.id != "1" || e.ev != EventDone {
t.Errorf("event = %+v, want {1 done}", e)
}
}
@@ -87,7 +87,7 @@ func TestScanner_FiresOnDone(t *testing.T) {
s := &recordingScanner{ch: make(chan struct{}, 4)}
f.w.SetScanner(s)
if err := f.w.Apply(context.Background(), 1); err != nil {
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
select {
@@ -97,7 +97,7 @@ func TestScanner_FiresOnDone(t *testing.T) {
}
}
func revertedDownload(id int64) *store.Download {
func revertedDownload(id string) *store.Download {
d := completedDownload(id)
d.State = store.StateReverted
return d
@@ -105,90 +105,90 @@ func revertedDownload(id int64) *store.Download {
func TestRelink_RevertedToRecognizing(t *testing.T) {
st := newMemStore()
st.put(revertedDownload(1))
st.put(revertedDownload("1"))
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
if err := w.Relink(context.Background(), 1); err != nil {
if err := w.Relink(context.Background(), "1"); err != nil {
t.Fatalf("Relink: %v", err)
}
if st.downloads[1].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
if st.downloads["1"].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads["1"].State)
}
if st.overrides[1][ovrForceReview] != "1" {
t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview])
if st.overrides["1"][ovrForceReview] != "1" {
t.Errorf("force_review override = %q, want 1", st.overrides["1"][ovrForceReview])
}
}
func TestRelink_CancelledToRecognizing(t *testing.T) {
st := newMemStore()
d := revertedDownload(1)
d := revertedDownload("1")
d.State = store.StateCancelled
st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
if err := w.Relink(context.Background(), 1); err != nil {
if err := w.Relink(context.Background(), "1"); err != nil {
t.Fatalf("Relink: %v", err)
}
if st.downloads[1].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
if st.downloads["1"].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads["1"].State)
}
if st.overrides[1][ovrForceReview] != "1" {
t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview])
if st.overrides["1"][ovrForceReview] != "1" {
t.Errorf("force_review override = %q, want 1", st.overrides["1"][ovrForceReview])
}
}
func TestRelink_RejectsActiveState(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1)) // не reverted/cancelled
st.put(completedDownload("1")) // не reverted/cancelled
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Relink(context.Background(), 1); err == nil {
if err := w.Relink(context.Background(), "1"); err == nil {
t.Fatal("ожидали ошибку для не-reverted/cancelled задачи, получили nil")
}
}
func TestRerecognize_ReviewToRecognizing(t *testing.T) {
st := newMemStore()
d := completedDownload(1)
d := completedDownload("1")
d.State = store.StateReview
st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Rerecognize(context.Background(), 1); err != nil {
if err := w.Rerecognize(context.Background(), "1"); err != nil {
t.Fatalf("Rerecognize: %v", err)
}
if st.downloads[1].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
if st.downloads["1"].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads["1"].State)
}
}
func TestRerecognize_RejectsNonReview(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1)) // completed, не review/deferred
st.put(completedDownload("1")) // completed, не review/deferred
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
if err := w.Rerecognize(context.Background(), 1); err == nil {
if err := w.Rerecognize(context.Background(), "1"); err == nil {
t.Fatal("ожидали ошибку для не-review задачи, получили nil")
}
}
func TestRelink_TorrentMissing(t *testing.T) {
st := newMemStore()
st.put(revertedDownload(1))
st.put(revertedDownload("1"))
qb := &fakeQbt{torrents: nil} // раздачи в qBittorrent нет
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Relink(context.Background(), 1); err == nil {
if err := w.Relink(context.Background(), "1"); err == nil {
t.Fatal("ожидали ошибку при отсутствии торрента, получили nil")
}
// Preflight приводит состояние к реальности: источника нет и цели нет
// (reverted — ссылки сняты) → deleted (см. state-reconciliation).
if st.downloads[1].State != store.StateDeleted {
t.Errorf("state = %q, want deleted (preflight привёл к реальности)", st.downloads[1].State)
if st.downloads["1"].State != store.StateDeleted {
t.Errorf("state = %q, want deleted (preflight привёл к реальности)", st.downloads["1"].State)
}
}
@@ -197,21 +197,21 @@ func TestRelink_TorrentMissing(t *testing.T) {
func TestRelink_ForceReviewSkipsAuto(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
// Готовим состояние «как после Relink»: reverted, force_review выставлен.
f.st.downloads[1].State = store.StateReverted
_ = f.st.SetOverride(context.Background(), 1, ovrForceReview, "1")
f.st.downloads["1"].State = store.StateReverted
_ = f.st.SetOverride(context.Background(), "1", ovrForceReview, "1")
auto := seriesResult()
auto.Decision.Auto = true
auto.Match = &recognize.Match{Provider: "tvdb", ProviderID: "42"}
f.w.recognizer = &fakeRecognizer{result: auto}
if err := f.w.Relink(context.Background(), 1); err != nil {
if err := f.w.Relink(context.Background(), "1"); err != nil {
t.Fatalf("Relink: %v", err)
}
f.w.recognizeOne(context.Background(), 1)
f.w.recognizeOne(context.Background(), "1")
if f.st.downloads[1].State != store.StateReview {
t.Fatalf("state = %q, want review (авто-раскладка не должна сработать)", f.st.downloads[1].State)
if f.st.downloads["1"].State != store.StateReview {
t.Fatalf("state = %q, want review (авто-раскладка не должна сработать)", f.st.downloads["1"].State)
}
if len(f.st.links) != 0 {
t.Errorf("file_links = %d, want 0 (ничего не линковали)", len(f.st.links))
@@ -220,19 +220,19 @@ func TestRelink_ForceReviewSkipsAuto(t *testing.T) {
// memStore — полноценный in-memory store для тестов Ф3.
type memStore struct {
downloads map[int64]*store.Download
downloads map[string]*store.Download
recs []*store.Recognition
hints map[int64][]string
overrides map[int64]map[string]string
hints map[string][]string
overrides map[string]map[string]string
links []store.FileLink
candidates []store.MetadataCandidate
}
func newMemStore() *memStore {
return &memStore{
downloads: map[int64]*store.Download{},
hints: map[int64][]string{},
overrides: map[int64]map[string]string{},
downloads: map[string]*store.Download{},
hints: map[string][]string{},
overrides: map[string]map[string]string{},
}
}
@@ -266,18 +266,18 @@ func (m *memStore) ListRecoverable(_ context.Context, codes ...string) ([]store.
return out, nil
}
func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
func (m *memStore) ExistsByInfohash(_ context.Context, hashes ...string) (bool, error) {
for _, d := range m.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash {
if hasAnyHash(d, hashes) {
return true, nil
}
}
return false, nil
}
func (m *memStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
func (m *memStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*store.Download, error) {
for _, d := range m.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
if hasAnyHash(d, hashes) && !d.State.IsTerminal() {
cp := *d
return &cp, nil
}
@@ -285,15 +285,51 @@ func (m *memStore) FindActiveByInfohash(_ context.Context, infohash string) (*st
return nil, nil
}
func (m *memStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
id := int64(len(m.downloads) + 1)
func (m *memStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) {
if existing, _ := m.FindActiveByInfohash(ctx, hashes...); existing != nil {
return existing, nil
}
id := itoa(len(m.downloads) + 1)
cp := *d
cp.ID = id
for _, h := range hashes {
h = store.NormalizeHash(h)
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
}
m.downloads[id] = &cp
return id, nil
d.ID = id
d.Infohashes = cp.Infohashes
return nil, nil
}
func (m *memStore) GetDownload(_ context.Context, id int64) (*store.Download, error) {
func (m *memStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
d, ok := m.downloads[id]
if !ok {
return os.ErrNotExist
}
for _, other := range m.downloads {
if other.ID != id && !other.State.IsTerminal() && hasAnyHash(other, hashList(d)) {
return store.ErrInfohashTaken
}
}
return m.SetDownloadState(ctx, id, st, code, msg)
}
func (m *memStore) AddInfohashes(_ context.Context, id string, hashes []string) error {
d, ok := m.downloads[id]
if !ok {
return os.ErrNotExist
}
for _, h := range hashes {
h = store.NormalizeHash(h)
if !hasAnyHash(d, []string{h}) {
d.Infohashes = append(d.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
}
}
return nil
}
func (m *memStore) GetDownload(_ context.Context, id string) (*store.Download, error) {
d, ok := m.downloads[id]
if !ok {
return nil, os.ErrNotExist
@@ -302,7 +338,7 @@ func (m *memStore) GetDownload(_ context.Context, id int64) (*store.Download, er
return &cp, nil
}
func (m *memStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
d := m.downloads[id]
d.State = st
d.ErrorCode = store.NullString(code)
@@ -310,28 +346,28 @@ func (m *memStore) SetDownloadState(_ context.Context, id int64, st store.State,
return nil
}
func (m *memStore) SetSourceMissCount(_ context.Context, id int64, n int) error {
func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error {
if d, ok := m.downloads[id]; ok {
d.SourceMissCount = n
}
return nil
}
func (m *memStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) error {
func (m *memStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) error {
if d, ok := m.downloads[id]; ok && !d.SourceAddedAt.Valid {
d.SourceAddedAt = store.NullString(store.FormatTime(t))
}
return nil
}
func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (int64, error) {
func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (string, error) {
for _, e := range m.recs {
if e.DownloadID == r.DownloadID {
e.IsCurrent = false
}
}
cp := *r
cp.ID = int64(len(m.recs) + 1)
cp.ID = itoa(len(m.recs) + 1)
cp.IsCurrent = true
cp.AttemptNo = 1
for _, e := range m.recs {
@@ -345,7 +381,7 @@ func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, re
return cp.ID, nil
}
func (m *memStore) GetCurrentRecognition(_ context.Context, downloadID int64) (*store.Recognition, error) {
func (m *memStore) GetCurrentRecognition(_ context.Context, downloadID string) (*store.Recognition, error) {
for _, e := range m.recs {
if e.DownloadID == downloadID && e.IsCurrent {
cp := *e
@@ -355,20 +391,20 @@ func (m *memStore) GetCurrentRecognition(_ context.Context, downloadID int64) (*
return nil, nil
}
func (m *memStore) AddHint(_ context.Context, id int64, text string) error {
func (m *memStore) AddHint(_ context.Context, id string, text string) error {
m.hints[id] = append(m.hints[id], text)
return nil
}
func (m *memStore) ListHints(_ context.Context, id int64) ([]string, error) { return m.hints[id], nil }
func (m *memStore) ListHints(_ context.Context, id string) ([]string, error) { return m.hints[id], nil }
func (m *memStore) SetOverride(_ context.Context, id int64, field, value string) error {
func (m *memStore) SetOverride(_ context.Context, id string, field, value string) error {
if m.overrides[id] == nil {
m.overrides[id] = map[string]string{}
}
m.overrides[id][field] = value
return nil
}
func (m *memStore) ListOverrides(_ context.Context, id int64) (map[string]string, error) {
func (m *memStore) ListOverrides(_ context.Context, id string) (map[string]string, error) {
return m.overrides[id], nil
}
@@ -376,7 +412,7 @@ func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) er
m.links = append(m.links, links...)
return nil
}
func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID int64, dstPaths []string) error {
func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID string, dstPaths []string) error {
if len(dstPaths) == 0 {
return nil
}
@@ -395,7 +431,7 @@ func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID int64, ds
}
return nil
}
func (m *memStore) LatestBatchID(_ context.Context, id int64) (string, error) {
func (m *memStore) LatestBatchID(_ context.Context, id string) (string, error) {
for i := len(m.links) - 1; i >= 0; i-- {
if m.links[i].DownloadID == id {
return m.links[i].ApplyBatchID, nil
@@ -425,12 +461,12 @@ func (m *memStore) DeleteFileLinksByBatch(_ context.Context, batch string) error
func (m *memStore) CreateCandidates(_ context.Context, cands []store.MetadataCandidate) error {
for _, c := range cands {
c.ID = int64(len(m.candidates) + 1)
c.ID = itoa(len(m.candidates) + 1)
m.candidates = append(m.candidates, c)
}
return nil
}
func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID int64) ([]store.MetadataCandidate, error) {
func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID string) ([]store.MetadataCandidate, error) {
var out []store.MetadataCandidate
for _, c := range m.candidates {
if c.RecognitionID == recID {
@@ -439,7 +475,7 @@ func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID int64) (
}
return out, nil
}
func (m *memStore) GetCandidate(_ context.Context, id int64) (*store.MetadataCandidate, error) {
func (m *memStore) GetCandidate(_ context.Context, id string) (*store.MetadataCandidate, error) {
for i := range m.candidates {
if m.candidates[i].ID == id {
cp := m.candidates[i]
@@ -448,7 +484,7 @@ func (m *memStore) GetCandidate(_ context.Context, id int64) (*store.MetadataCan
}
return nil, nil
}
func (m *memStore) SetCandidateChosen(_ context.Context, recID, id int64) error {
func (m *memStore) SetCandidateChosen(_ context.Context, recID, id string) error {
for i := range m.candidates {
if m.candidates[i].RecognitionID == recID {
m.candidates[i].Chosen = m.candidates[i].ID == id
@@ -501,10 +537,10 @@ func itoa(n int) string {
const ihTest = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
func completedDownload(id int64) *store.Download {
func completedDownload(id string) *store.Download {
return &store.Download{
ID: id, State: store.StateCompleted, SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + ihTest, Infohash: store.NullString(ihTest),
SourceRef: "magnet:?xt=urn:btih:" + ihTest, Infohashes: hashesOf(id, ihTest),
Context: "ctx",
}
}
@@ -526,7 +562,7 @@ func seriesResult() recognize.Result {
func TestRecognizeOne_CompletedToReview(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1))
st.put(completedDownload("1"))
qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", Category: "jellybit"}},
files: []qbt.File{{Name: "Show/e1.mkv", Size: 100}, {Name: "Show/e2.mkv", Size: 100}},
@@ -534,12 +570,12 @@ func TestRecognizeOne_CompletedToReview(t *testing.T) {
rec := &fakeRecognizer{result: seriesResult()}
w := testWorkerWith(st, qb, rec, nil)
w.recognizeOne(context.Background(), 1)
w.recognizeOne(context.Background(), "1")
if st.downloads[1].State != store.StateReview {
t.Fatalf("state = %q, want review", st.downloads[1].State)
if st.downloads["1"].State != store.StateReview {
t.Fatalf("state = %q, want review", st.downloads["1"].State)
}
cur, _ := st.GetCurrentRecognition(context.Background(), 1)
cur, _ := st.GetCurrentRecognition(context.Background(), "1")
if cur == nil || cur.Title.String != "Show" {
t.Fatalf("recognition = %+v", cur)
}
@@ -554,7 +590,7 @@ func TestRecognizeOne_CompletedToReview(t *testing.T) {
// распознавание падало с «torrent not found in qBittorrent».
func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1))
st.put(completedDownload("1"))
qb := &fakeQbt{
torrents: []qbt.Torrent{{
Hash: ihTest, Name: "ThePitt", SavePath: "/d",
@@ -565,14 +601,14 @@ func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
rec := &fakeRecognizer{result: seriesResult()}
w := testWorkerWith(st, qb, rec, nil)
w.recognizeOne(context.Background(), 1)
w.recognizeOne(context.Background(), "1")
if st.downloads[1].State != store.StateReview {
t.Fatalf("state = %q, want review", st.downloads[1].State)
if st.downloads["1"].State != store.StateReview {
t.Fatalf("state = %q, want review", st.downloads["1"].State)
}
// Recognizer вернул бы Title="Show" только если торрент найден по infohash;
// при потере (фильтр по категории) был бы пустой план с причиной «not found».
cur, _ := st.GetCurrentRecognition(context.Background(), 1)
cur, _ := st.GetCurrentRecognition(context.Background(), "1")
if cur == nil || cur.Title.String != "Show" {
t.Fatalf("recognizer did not run on found torrent (title=%q): torrent must be found by infohash despite foreign category",
func() string {
@@ -586,40 +622,40 @@ func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1))
st.put(completedDownload("1"))
qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
files: []qbt.File{{Name: "Show/e1.mkv", Size: 100}},
}
// Во время вызова LLM задачу отменяют.
rec := &fakeRecognizer{result: seriesResult(), onCall: func() {
st.downloads[1].State = store.StateCancelled
st.downloads["1"].State = store.StateCancelled
}}
w := testWorkerWith(st, qb, rec, nil)
w.recognizeOne(context.Background(), 1)
w.recognizeOne(context.Background(), "1")
if st.downloads[1].State != store.StateCancelled {
t.Errorf("state = %q, want cancelled (result discarded)", st.downloads[1].State)
if st.downloads["1"].State != store.StateCancelled {
t.Errorf("state = %q, want cancelled (result discarded)", st.downloads["1"].State)
}
if cur, _ := st.GetCurrentRecognition(context.Background(), 1); cur != nil {
if cur, _ := st.GetCurrentRecognition(context.Background(), "1"); cur != nil {
t.Error("recognition must not be persisted after discard")
}
}
func TestRecognizeOne_SignalsErrorToReview(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1))
st.put(completedDownload("1"))
qb := &fakeQbt{torrents: nil} // торрент пропал
rec := &fakeRecognizer{result: seriesResult()}
w := testWorkerWith(st, qb, rec, nil)
w.recognizeOne(context.Background(), 1)
w.recognizeOne(context.Background(), "1")
if st.downloads[1].State != store.StateReview {
t.Fatalf("state = %q, want review", st.downloads[1].State)
if st.downloads["1"].State != store.StateReview {
t.Fatalf("state = %q, want review", st.downloads["1"].State)
}
cur, _ := st.GetCurrentRecognition(context.Background(), 1)
cur, _ := st.GetCurrentRecognition(context.Background(), "1")
if cur == nil || len(cur.ReasonList()) == 0 {
t.Fatal("expected review with reason")
}
@@ -627,82 +663,82 @@ func TestRecognizeOne_SignalsErrorToReview(t *testing.T) {
func TestRefine_AddsHintAndRerecognizes(t *testing.T) {
st := newMemStore()
d := completedDownload(1)
d := completedDownload("1")
d.State = store.StateReview
st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Refine(context.Background(), 1, "это второй сезон"); err != nil {
if err := w.Refine(context.Background(), "1", "это второй сезон"); err != nil {
t.Fatalf("Refine: %v", err)
}
if st.downloads[1].State != store.StateRecognizing {
t.Errorf("state = %q, want recognizing", st.downloads[1].State)
if st.downloads["1"].State != store.StateRecognizing {
t.Errorf("state = %q, want recognizing", st.downloads["1"].State)
}
if h := st.hints[1]; len(h) != 1 || h[0] != "это второй сезон" {
if h := st.hints["1"]; len(h) != 1 || h[0] != "это второй сезон" {
t.Errorf("hints = %v", h)
}
if err := w.Refine(context.Background(), 1, " "); err == nil {
if err := w.Refine(context.Background(), "1", " "); err == nil {
t.Error("empty hint must be rejected")
}
}
func TestSetType(t *testing.T) {
st := newMemStore()
d := completedDownload(1)
d := completedDownload("1")
d.State = store.StateReview
st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.SetType(context.Background(), 1, "series"); err != nil {
if err := w.SetType(context.Background(), "1", "series"); err != nil {
t.Fatalf("SetType: %v", err)
}
if st.overrides[1][ovrMediaType] != "series" {
t.Errorf("override = %v", st.overrides[1])
if st.overrides["1"][ovrMediaType] != "series" {
t.Errorf("override = %v", st.overrides["1"])
}
if st.downloads[1].State != store.StateRecognizing {
t.Errorf("state = %q, want recognizing", st.downloads[1].State)
if st.downloads["1"].State != store.StateRecognizing {
t.Errorf("state = %q, want recognizing", st.downloads["1"].State)
}
if err := w.SetType(context.Background(), 1, "cartoon"); err == nil {
if err := w.SetType(context.Background(), "1", "cartoon"); err == nil {
t.Error("invalid type must be rejected")
}
}
func TestIgnoreFile(t *testing.T) {
st := newMemStore()
d := completedDownload(1)
d := completedDownload("1")
d.State = store.StateReview
st.put(d)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
if err := w.IgnoreFile(context.Background(), 1, "Show/sample.mkv"); err != nil {
if err := w.IgnoreFile(context.Background(), "1", "Show/sample.mkv"); err != nil {
t.Fatalf("IgnoreFile: %v", err)
}
if err := w.IgnoreFile(context.Background(), 1, "Show/sample.mkv"); err != nil { // повтор не дублирует
if err := w.IgnoreFile(context.Background(), "1", "Show/sample.mkv"); err != nil { // повтор не дублирует
t.Fatalf("IgnoreFile repeat: %v", err)
}
ignored := parseIgnored(st.overrides[1][ovrIgnoredFiles])
ignored := parseIgnored(st.overrides["1"][ovrIgnoredFiles])
if len(ignored) != 1 || ignored[0] != "Show/sample.mkv" {
t.Errorf("ignored = %v", ignored)
}
if st.downloads[1].State != store.StateReview {
t.Errorf("ignore must keep review, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateReview {
t.Errorf("ignore must keep review, got %q", st.downloads["1"].State)
}
}
func TestDefer(t *testing.T) {
st := newMemStore()
d := completedDownload(1)
d := completedDownload("1")
d.State = store.StateReview
st.put(d)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
if err := w.Defer(context.Background(), 1); err != nil {
if err := w.Defer(context.Background(), "1"); err != nil {
t.Fatalf("Defer: %v", err)
}
if st.downloads[1].State != store.StateDeferred {
t.Errorf("state = %q, want deferred", st.downloads[1].State)
if st.downloads["1"].State != store.StateDeferred {
t.Errorf("state = %q, want deferred", st.downloads["1"].State)
}
}
@@ -739,12 +775,12 @@ func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
}
st := newMemStore()
d := completedDownload(1)
d := completedDownload("1")
d.State = store.StateReview
st.put(d)
planJSON, _ := json.Marshal(plan)
st.recs = append(st.recs, &store.Recognition{
ID: 1, DownloadID: 1, IsCurrent: true, Plan: store.NullString(string(planJSON)),
ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
})
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: downloads, Category: "jellybit"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, lay)
@@ -755,11 +791,11 @@ func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
func TestApply_LinksAndDone(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
if err := f.w.Apply(context.Background(), 1); err != nil {
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
if f.st.downloads[1].State != store.StateDone {
t.Fatalf("state = %q, want done", f.st.downloads[1].State)
if f.st.downloads["1"].State != store.StateDone {
t.Fatalf("state = %q, want done", f.st.downloads["1"].State)
}
if len(f.st.links) != 2 {
t.Fatalf("file_links = %d, want 2", len(f.st.links))
@@ -780,9 +816,9 @@ func TestApply_IgnoredFileSkipped(t *testing.T) {
Src: "Show/sample.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e,
})
f := newApplyFixture(t, plan)
_ = f.st.SetOverride(context.Background(), 1, ovrIgnoredFiles, `["Show/sample.mkv"]`)
_ = f.st.SetOverride(context.Background(), "1", ovrIgnoredFiles, `["Show/sample.mkv"]`)
if err := f.w.Apply(context.Background(), 1); err != nil {
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
if len(f.st.links) != 2 { // sample пропущен
@@ -798,12 +834,12 @@ func TestApply_CollisionStaysReview(t *testing.T) {
_ = os.MkdirAll(filepath.Dir(dst), 0o755)
_ = os.WriteFile(dst, []byte("foreign"), 0o644)
err := f.w.Apply(context.Background(), 1)
err := f.w.Apply(context.Background(), "1")
if err == nil {
t.Fatal("want collision error")
}
if f.st.downloads[1].State != store.StateReview {
t.Errorf("state = %q, want review after collision", f.st.downloads[1].State)
if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, want review after collision", f.st.downloads["1"].State)
}
b, _ := os.ReadFile(dst)
if string(b) != "foreign" {
@@ -817,20 +853,20 @@ func TestApply_SupersedesForeignOwnerOfPath(t *testing.T) {
plan := seriesResult().Plan
f := newApplyFixture(t, plan)
e01 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
f.st.put(completedDownload(2))
f.st.put(completedDownload("2"))
f.st.links = append(f.st.links, store.FileLink{
DownloadID: 2, ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
DownloadID: "2", ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
Kind: "video", Status: "linked",
})
if err := f.w.Apply(context.Background(), 1); err != nil {
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
// Чужая ссылка на перехваченный путь — superseded.
var foreign *store.FileLink
for i := range f.st.links {
if f.st.links[i].DownloadID == 2 {
if f.st.links[i].DownloadID == "2" {
foreign = &f.st.links[i]
}
}
@@ -839,12 +875,12 @@ func TestApply_SupersedesForeignOwnerOfPath(t *testing.T) {
}
// Свои ссылки (id=1) не тронуты — download_id != self.
for _, l := range f.st.links {
if l.DownloadID == 1 && !isLaidOut(l.Status) {
if l.DownloadID == "1" && !isLaidOut(l.Status) {
t.Errorf("своя ссылка %q стала %q, ожидали разложенную", l.DstPath, l.Status)
}
}
// Прежняя загрузка больше не владеет путём → цель отсутствует.
present, err := f.w.targetPresent(context.Background(), 2)
present, err := f.w.targetPresent(context.Background(), "2")
if err != nil {
t.Fatalf("targetPresent: %v", err)
}
@@ -861,17 +897,17 @@ func TestApply_CollisionKeepsForeignOwner(t *testing.T) {
e01 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
_ = os.MkdirAll(filepath.Dir(e01), 0o755)
_ = os.WriteFile(e01, []byte("foreign"), 0o644)
f.st.put(completedDownload(2))
f.st.put(completedDownload("2"))
f.st.links = append(f.st.links, store.FileLink{
DownloadID: 2, ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
DownloadID: "2", ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
Kind: "video", Status: "linked",
})
if err := f.w.Apply(context.Background(), 1); err == nil {
if err := f.w.Apply(context.Background(), "1"); err == nil {
t.Fatal("want collision error")
}
for _, l := range f.st.links {
if l.DownloadID == 2 && l.Status != "linked" {
if l.DownloadID == "2" && l.Status != "linked" {
t.Errorf("чужая ссылка стала %q при коллизии, владение не должно отбираться", l.Status)
}
}
@@ -880,7 +916,7 @@ func TestApply_CollisionKeepsForeignOwner(t *testing.T) {
func TestUndo_RevertsLinks(t *testing.T) {
plan := seriesResult().Plan
f := newApplyFixture(t, plan)
if err := f.w.Apply(context.Background(), 1); err != nil {
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
@@ -888,11 +924,11 @@ func TestUndo_RevertsLinks(t *testing.T) {
t.Fatalf("precondition: link must exist: %v", err)
}
if err := f.w.Undo(context.Background(), 1); err != nil {
if err := f.w.Undo(context.Background(), "1"); err != nil {
t.Fatalf("Undo: %v", err)
}
if f.st.downloads[1].State != store.StateReverted {
t.Errorf("state = %q, want reverted", f.st.downloads[1].State)
if f.st.downloads["1"].State != store.StateReverted {
t.Errorf("state = %q, want reverted", f.st.downloads["1"].State)
}
if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Errorf("link must be removed: %v", err)
@@ -909,9 +945,9 @@ func TestUndo_RevertsLinks(t *testing.T) {
func TestReviewData(t *testing.T) {
plan := seriesResult().Plan
f := newApplyFixture(t, plan)
_ = f.st.AddHint(context.Background(), 1, "подсказка")
_ = f.st.AddHint(context.Background(), "1", "подсказка")
rd, err := f.w.ReviewData(context.Background(), 1)
rd, err := f.w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
@@ -964,7 +1000,7 @@ func TestRecognizeOne_AutoApplies(t *testing.T) {
lay, _ := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}, nil)
st := newMemStore()
st.put(completedDownload(1))
st.put(completedDownload("1"))
qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: downloads, Category: "jellybit"}},
files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}, {Name: "Show/e2.mkv", Size: 1}},
@@ -976,10 +1012,10 @@ func TestRecognizeOne_AutoApplies(t *testing.T) {
}}
w := testWorkerWith(st, qb, rec, lay)
w.recognizeOne(context.Background(), 1)
w.recognizeOne(context.Background(), "1")
if st.downloads[1].State != store.StateDone {
t.Fatalf("state = %q, want done (auto)", st.downloads[1].State)
if st.downloads["1"].State != store.StateDone {
t.Fatalf("state = %q, want done (auto)", st.downloads["1"].State)
}
// Provider-тег попал в имя папки.
want := filepath.Join(series, "Show (2006) [tmdbid-42]", "Season 02", "Show (2006) S02E01.mkv")
@@ -996,7 +1032,7 @@ func TestApply_UsesProviderTag(t *testing.T) {
f.st.recs[0].Provider = store.NullString("tmdb")
f.st.recs[0].ProviderID = store.NullString("603")
if err := f.w.Apply(context.Background(), 1); err != nil {
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
want := filepath.Join(f.series, "Show (2006) [tmdbid-603]", "Season 02", "Show (2006) S02E01.mkv")
@@ -1026,15 +1062,15 @@ func TestProviderTag(t *testing.T) {
func reviewWithCandidate(t *testing.T, cand store.MetadataCandidate) (*Worker, *memStore) {
t.Helper()
st := newMemStore()
d := completedDownload(1)
d := completedDownload("1")
d.State = store.StateReview
st.put(d)
planJSON, _ := json.Marshal(recognize.Plan{Type: recognize.MediaSeries, Title: "Догадка", Year: 2000})
st.recs = append(st.recs, &store.Recognition{
ID: 1, DownloadID: 1, IsCurrent: true, Plan: store.NullString(string(planJSON)),
ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
Provider: store.NullString("none"),
})
cand.RecognitionID = 1
cand.RecognitionID = "1"
_ = st.CreateCandidates(context.Background(), []store.MetadataCandidate{cand})
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
return w, st
@@ -1042,7 +1078,7 @@ func reviewWithCandidate(t *testing.T, cand store.MetadataCandidate) (*Worker, *
func TestRecognizeOne_PersistsCandidates(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1))
st.put(completedDownload("1"))
qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
files: []qbt.File{{Name: "e1.mkv", Size: 1}},
@@ -1054,7 +1090,7 @@ func TestRecognizeOne_PersistsCandidates(t *testing.T) {
}
w := testWorkerWith(st, qb, &fakeRecognizer{result: res}, nil)
w.recognizeOne(context.Background(), 1)
w.recognizeOne(context.Background(), "1")
if len(st.candidates) != 2 {
t.Fatalf("candidates = %d, want 2", len(st.candidates))
@@ -1080,10 +1116,10 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
})
candID := st.candidates[0].ID
if err := w.ChooseCandidate(context.Background(), 1, candID); err != nil {
if err := w.ChooseCandidate(context.Background(), "1", candID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
ov := st.overrides[1]
ov := st.overrides["1"]
if ov[ovrProvider] != "tvdb" || ov[ovrProviderID] != "269613" ||
ov[ovrTitle] != "Fargo" || ov[ovrYear] != "2014" {
t.Errorf("overrides = %v", ov)
@@ -1092,7 +1128,7 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
t.Error("кандидат не помечен выбранным")
}
// Эффективный план берёт каноническое имя/год и тег [tvdbid-...].
plan, tag, err := w.effectivePlan(context.Background(), 1)
plan, tag, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
@@ -1106,38 +1142,38 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
func TestChooseCandidate_RejectsForeign(t *testing.T) {
w, _ := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
if err := w.ChooseCandidate(context.Background(), 1, 999); err == nil {
if err := w.ChooseCandidate(context.Background(), "1", "999"); err == nil {
t.Error("чужой кандидат должен отклоняться")
}
}
func TestSetProviderID(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
if err := w.SetProviderID(context.Background(), 1, "TMDB", " 603 "); err != nil {
if err := w.SetProviderID(context.Background(), "1", "TMDB", " 603 "); err != nil {
t.Fatalf("SetProviderID: %v", err)
}
if st.overrides[1][ovrProvider] != "tmdb" || st.overrides[1][ovrProviderID] != "603" {
t.Errorf("overrides = %v", st.overrides[1])
if st.overrides["1"][ovrProvider] != "tmdb" || st.overrides["1"][ovrProviderID] != "603" {
t.Errorf("overrides = %v", st.overrides["1"])
}
if err := w.SetProviderID(context.Background(), 1, "kinopoisk", "1"); err == nil {
if err := w.SetProviderID(context.Background(), "1", "kinopoisk", "1"); err == nil {
t.Error("недопустимый провайдер должен отклоняться")
}
if err := w.SetProviderID(context.Background(), 1, "tmdb", ""); err == nil {
if err := w.SetProviderID(context.Background(), "1", "tmdb", ""); err == nil {
t.Error("пустой id должен отклоняться")
}
}
func TestClearProvider(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
_ = st.SetOverride(context.Background(), 1, ovrProvider, "tvdb")
if err := w.ClearProvider(context.Background(), 1); err != nil {
_ = st.SetOverride(context.Background(), "1", ovrProvider, "tvdb")
if err := w.ClearProvider(context.Background(), "1"); err != nil {
t.Fatalf("ClearProvider: %v", err)
}
if st.overrides[1][ovrProvider] != "none" {
t.Errorf("provider override = %q, want none", st.overrides[1][ovrProvider])
if st.overrides["1"][ovrProvider] != "none" {
t.Errorf("provider override = %q, want none", st.overrides["1"][ovrProvider])
}
// «Без базы» → пустой тег.
_, tag, _ := w.effectivePlan(context.Background(), 1)
_, tag, _ := w.effectivePlan(context.Background(), "1")
if tag != "" {
t.Errorf("tag = %q, want empty", tag)
}
@@ -1148,10 +1184,10 @@ func TestReviewData_IncludesCandidates(t *testing.T) {
Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
})
candID := st.candidates[0].ID
if err := w.ChooseCandidate(context.Background(), 1, candID); err != nil {
if err := w.ChooseCandidate(context.Background(), "1", candID); err != nil {
t.Fatal(err)
}
rd, err := w.ReviewData(context.Background(), 1)
rd, err := w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
@@ -1169,7 +1205,7 @@ func TestReviewData_IncludesCandidates(t *testing.T) {
func TestToStoreCandidates_URL(t *testing.T) {
// Кандидат с URL: URL должен быть проброшен как непустой NullString.
// Кандидат без URL: URL должен быть пустым NullString (Valid=false → NULL).
candURL := toStoreCandidates(1, []metadata.Candidate{
candURL := toStoreCandidates("1", []metadata.Candidate{
{Provider: "tmdb", ID: "603", Title: "With URL", URL: "https://www.themoviedb.org/movie/603"},
{Provider: "tvdb", ID: "1", Title: "Without URL", URL: ""},
})
+93 -42
View File
@@ -13,12 +13,15 @@ package worker
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"sync"
"time"
"git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/logctx"
"git.vakhrushev.me/av/jellybit/internal/qbt"
@@ -39,34 +42,35 @@ const (
type Store interface {
ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error)
ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error)
GetDownload(ctx context.Context, id int64) (*store.Download, error)
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
SetSourceMissCount(ctx context.Context, id int64, n int) error
SetSourceAddedAt(ctx context.Context, id int64, t time.Time) error
GetDownload(ctx context.Context, id string) (*store.Download, error)
SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error
SetSourceMissCount(ctx context.Context, id string, n int) error
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
// Discovery (усыновление раздач по категории/тегу).
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
// Идентичность/инвариант «одна активная загрузка на infohash».
ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error)
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error)
ActivateIfNoOtherActive(ctx context.Context, id string, state store.State, errCode, errMsg string) error
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
// Ф3: распознавание, ревью, раскладка.
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (int64, error)
GetCurrentRecognition(ctx context.Context, downloadID int64) (*store.Recognition, error)
AddHint(ctx context.Context, downloadID int64, text string) error
ListHints(ctx context.Context, downloadID int64) ([]string, error)
SetOverride(ctx context.Context, downloadID int64, field, value string) error
ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error)
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (string, error)
GetCurrentRecognition(ctx context.Context, downloadID string) (*store.Recognition, error)
AddHint(ctx context.Context, downloadID string, text string) error
ListHints(ctx context.Context, downloadID string) ([]string, error)
SetOverride(ctx context.Context, downloadID string, field, value string) error
ListOverrides(ctx context.Context, downloadID string) (map[string]string, error)
CreateFileLinks(ctx context.Context, links []store.FileLink) error
SupersedeForeignLinks(ctx context.Context, downloadID int64, dstPaths []string) error
LatestBatchID(ctx context.Context, downloadID int64) (string, error)
SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error
LatestBatchID(ctx context.Context, downloadID string) (string, error)
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
DeleteFileLinksByBatch(ctx context.Context, batchID string) error
// Кандидаты базы метаданных (ручной выбор в review).
CreateCandidates(ctx context.Context, cands []store.MetadataCandidate) error
ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]store.MetadataCandidate, error)
GetCandidate(ctx context.Context, id int64) (*store.MetadataCandidate, error)
SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error
ListCandidatesByRecognition(ctx context.Context, recognitionID string) ([]store.MetadataCandidate, error)
GetCandidate(ctx context.Context, id string) (*store.MetadataCandidate, error)
SetCandidateChosen(ctx context.Context, recognitionID, candidateID string) error
}
// QBittorrent — нужная worker часть клиента qBittorrent.
@@ -111,7 +115,7 @@ const (
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
type Notifier interface {
Notify(ctx context.Context, downloadID int64, event NotifyEvent)
Notify(ctx context.Context, downloadID string, event NotifyEvent)
}
// Scanner — триггер пересканирования медиатеки Jellyfin. Вызывается
@@ -196,7 +200,7 @@ type Worker struct {
// время последнего пинга). Мерцающий stalled-торрент колеблется
// stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память
// процесса: при рестарте дебаунс сбрасывается — допустимо. Доступ под w.mu.
failNotified map[int64]time.Time
failNotified map[string]time.Time
}
// failNotifyDebounce — минимальный интервал между уведомлениями о падении
@@ -221,7 +225,7 @@ func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log
log: log,
now: time.Now,
newID: defaultBatchID,
failNotified: map[int64]time.Time{},
failNotified: map[string]time.Time{},
live: map[string]Live{},
}
}
@@ -246,15 +250,16 @@ func (w *Worker) setLive(snap map[string]Live) {
w.liveMu.Unlock()
}
// defaultBatchID — уникальный идентификатор батча раскладки.
// defaultBatchID — идентификатор батча раскладки (ULID, единая точка
// генерации id — internal/ident; сортируем по времени, удобен в логах).
func defaultBatchID() string {
return fmt.Sprintf("b-%d", time.Now().UnixNano())
return ident.NewID()
}
// scoped кладёт в ctx scoped-логгер загрузки (capability + download_id
// [+ infohash]); стадии и внешние клиенты достают его из ctx и дописывают эти
// ключи на каждую запись сами — без ручного доклеивания download_id.
func (w *Worker) scoped(ctx context.Context, capability string, id int64, infohash string) context.Context {
func (w *Worker) scoped(ctx context.Context, capability string, id string, infohash string) context.Context {
log := w.log.With("capability", capability, "download_id", id)
if infohash != "" {
log = log.With("infohash", infohash)
@@ -325,15 +330,16 @@ func (w *Worker) Poll(ctx context.Context) error {
return fmt.Errorf("poll: list active: %w", err)
}
for _, d := range active {
if !d.Infohash.Valid {
if len(d.Infohashes) == 0 {
continue // нечем сопоставить (в Ф1 не случается: magnet всегда с infohash)
}
t, ok := byHash[strings.ToLower(d.Infohash.String)]
t, ok := torrentFor(d, byHash)
if !ok {
w.log.Warn("active download not found in qbittorrent",
"capability", capIngest, "download_id", d.ID, "infohash", d.Infohash.String)
"capability", capIngest, "download_id", d.ID, "infohash", d.PrimaryInfohash())
continue
}
w.captureInfohashes(ctx, d, t)
w.captureSourceAddedAt(ctx, d, t)
w.reconcile(ctx, d, t)
}
@@ -351,7 +357,7 @@ func (w *Worker) Poll(ctx context.Context) error {
// reconcile двигает одну задачу по состоянию её торрента. Вызывается под
// w.mu.
func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent) {
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
switch classify(t.State) {
case classReady:
w.transition(ctx, d, store.StateCompleted, "", "")
@@ -397,6 +403,39 @@ func (w *Worker) captureSourceAddedAt(ctx context.Context, d store.Download, t q
}
}
// torrentFor ищет торрент загрузки в карте byHash по любому из её хешей.
func torrentFor(d store.Download, byHash map[string]qbt.Torrent) (qbt.Torrent, bool) {
for _, h := range d.HashList() {
if t, ok := byHash[h]; ok {
return t, true
}
}
return qbt.Torrent{}, false
}
// captureInfohashes дописывает загрузке хеши, которые qBittorrent знает, а мы
// ещё нет (гибридный торрент раскрывает v1+v2 после получения метаданных).
// Хеши собирает torrentHashes (усечённый t.Hash v2-only раздач отсеян).
// AddInfohashes под гардом: хеш, которым владеет другая активная задача,
// дописан не будет (ErrInfohashTaken). Учётная операция: сбой не двигает
// задачу, лишь логируем WARN. Под w.mu.
func (w *Worker) captureInfohashes(ctx context.Context, d store.Download, t qbt.Torrent) {
known := d.HashList()
var missing []string
for _, h := range torrentHashes(t) {
if !slices.Contains(known, h) {
missing = append(missing, h)
}
}
if len(missing) == 0 {
return
}
if err := w.store.AddInfohashes(ctx, d.ID, missing); err != nil {
w.log.Warn("capture infohashes failed",
"capability", capIngest, "download_id", d.ID, "error", err)
}
}
// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис,
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
// отдал added_on.
@@ -453,7 +492,7 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
// Скан Jellyfin — неблокирующе и вне w.mu, в фоновом ctx со scoped-логгером
// (download_id для корреляции ext.*-записи клиента). Недоступность Jellyfin
// на задачу не влияет; ошибку вызова логирует сам клиент (ext.*), здесь гасим.
gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.Infohash.String)
gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.PrimaryInfohash())
go func() { _ = w.scanner.RefreshLibraries(gctx) }()
}
}
@@ -462,7 +501,7 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
// (мерцающий stalled-торрент: stuck↔downloading), чтобы не спамить. Вызывается
// под w.mu. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы
// флаппинг.
func (w *Worker) shouldNotifyFail(id int64) bool {
func (w *Worker) shouldNotifyFail(id string) bool {
now := w.now()
if last, ok := w.failNotified[id]; ok && now.Sub(last) < failNotifyDebounce {
return false
@@ -479,7 +518,7 @@ func (w *Worker) shouldNotifyFail(id int64) bool {
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
// раздачу (источник неприкосновенен).
func (w *Worker) Cancel(ctx context.Context, id int64) error {
func (w *Worker) Cancel(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -488,18 +527,18 @@ func (w *Worker) Cancel(ctx context.Context, id int64) error {
return fmt.Errorf("cancel: %w", err)
}
if d.State.IsTerminal() {
return fmt.Errorf("cancel: download %d is already terminal (%s)", id, d.State)
return fmt.Errorf("cancel: download %s is already terminal (%s)", id, d.State)
}
if err := w.store.SetDownloadState(ctx, id, store.StateCancelled, "", ""); err != nil {
return fmt.Errorf("cancel: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("download cancelled", "from", d.State)
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download cancelled", "from", d.State)
return nil
}
// Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в
// qBittorrent и возвращает в downloading.
func (w *Worker) Retry(ctx context.Context, id int64) error {
func (w *Worker) Retry(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -508,32 +547,44 @@ func (w *Worker) Retry(ctx context.Context, id int64) error {
return fmt.Errorf("retry: %w", err)
}
if d.State != store.StateFailed && d.State != store.StateStuck {
return fmt.Errorf("retry: download %d is %s, only failed/stuck are retriable", id, d.State)
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable", id, d.State)
}
// Если раздача уже жива в qBittorrent — перецепляемся к ней, повторный Add
// не нужен (и вреден: вслепую дублировал бы торрент). Add — только когда
// источника в qBittorrent нет. Базис таймаута берётся от added_on, поэтому
// возврат в downloading не роняет задачу снова на ближайшем тике.
alive := false
if d.Infohash.Valid {
_, alive, err = w.torrentByInfohash(ctx, d.Infohash.String)
if hashes := d.HashList(); len(hashes) > 0 {
_, alive, err = w.torrentByInfohash(ctx, hashes)
if err != nil {
return fmt.Errorf("retry: %w", err)
}
}
// Гард инварианта — ДО побочного эффекта в qBittorrent: пока задача лежала
// в failed, тем же infohash могла завладеть другая активная задача — тогда
// отказываем, не добавив торрент повторно (см. design ulid-identity, D4).
if err := w.store.ActivateIfNoOtherActive(ctx, id, store.StateDownloading, "", ""); err != nil {
if errors.Is(err, store.ErrInfohashTaken) {
return fmt.Errorf("retry: для этого торрента уже есть другая активная задача: %w", ErrConflict)
}
return fmt.Errorf("retry: %w", err)
}
if !alive && d.SourceType == store.SourceMagnet {
if err := w.qbt.Add(ctx, qbt.AddRequest{
URLs: []string{d.SourceRef},
Category: w.cfg.Category,
SavePath: w.cfg.SavePath,
}); err != nil {
// Активация уже прошла — откатываем задачу в прежнее состояние,
// чтобы не оставить «качающуюся» задачу без раздачи в qBittorrent.
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
w.log.Error("retry rollback failed",
"capability", capReview, "download_id", id, "error", rbErr)
}
return fmt.Errorf("retry: add to qbittorrent: %w", err)
}
}
if err := w.store.SetDownloadState(ctx, id, store.StateDownloading, "", ""); err != nil {
return fmt.Errorf("retry: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("download retried", "from", d.State)
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download retried", "from", d.State)
return nil
}
+165 -47
View File
@@ -2,6 +2,7 @@ package worker
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
@@ -20,12 +21,12 @@ const (
)
type fakeStore struct {
downloads map[int64]*store.Download
downloads map[string]*store.Download
transitions []transition
}
type transition struct {
id int64
id string
state store.State
}
@@ -58,27 +59,39 @@ func (f *fakeStore) ListRecoverable(_ context.Context, codes ...string) ([]store
return out, nil
}
func (f *fakeStore) GetDownload(_ context.Context, id int64) (*store.Download, error) {
func (f *fakeStore) GetDownload(_ context.Context, id string) (*store.Download, error) {
d, ok := f.downloads[id]
if !ok {
return nil, fmt.Errorf("download %d not found", id)
return nil, fmt.Errorf("download %s not found", id)
}
cp := *d
return &cp, nil
}
func (f *fakeStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
// hasAnyHash сообщает, владеет ли загрузка любым из hashes.
func hasAnyHash(d *store.Download, hashes []string) bool {
for _, own := range d.Infohashes {
for _, h := range hashes {
if own.Infohash == store.NormalizeHash(h) {
return true
}
}
}
return false
}
func (f *fakeStore) ExistsByInfohash(_ context.Context, hashes ...string) (bool, error) {
for _, d := range f.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash {
if hasAnyHash(d, hashes) {
return true, nil
}
}
return false, nil
}
func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
func (f *fakeStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*store.Download, error) {
for _, d := range f.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
if hasAnyHash(d, hashes) && !d.State.IsTerminal() {
cp := *d
return &cp, nil
}
@@ -86,18 +99,62 @@ func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*s
return nil, nil
}
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
id := int64(len(f.downloads) + 1)
func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) {
if existing, _ := f.FindActiveByInfohash(ctx, hashes...); existing != nil {
return existing, nil
}
id := fmt.Sprintf("%d", len(f.downloads)+1)
cp := *d
cp.ID = id
for _, h := range hashes {
h = store.NormalizeHash(h)
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
}
f.downloads[id] = &cp
return id, nil
d.ID = id
d.Infohashes = cp.Infohashes
return nil, nil
}
func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
func (f *fakeStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %d not found", id)
return fmt.Errorf("download %s not found", id)
}
for _, other := range f.downloads {
if other.ID != id && !other.State.IsTerminal() && hasAnyHash(other, hashList(d)) {
return fmt.Errorf("activate %s: %w", id, store.ErrInfohashTaken)
}
}
return f.SetDownloadState(ctx, id, st, code, msg)
}
func hashList(d *store.Download) []string {
out := make([]string, len(d.Infohashes))
for i, h := range d.Infohashes {
out[i] = h.Infohash
}
return out
}
func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %s not found", id)
}
for _, h := range hashes {
h = store.NormalizeHash(h)
if !hasAnyHash(d, []string{h}) {
d.Infohashes = append(d.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
}
}
return nil
}
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %s not found", id)
}
d.State = st
d.ErrorCode = store.NullString(code)
@@ -106,19 +163,19 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State
return nil
}
func (f *fakeStore) SetSourceMissCount(_ context.Context, id int64, n int) error {
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %d not found", id)
return fmt.Errorf("download %s not found", id)
}
d.SourceMissCount = n
return nil
}
func (f *fakeStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) error {
func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %d not found", id)
return fmt.Errorf("download %s not found", id)
}
if !d.SourceAddedAt.Valid { // гард как в store: пишем однократно
d.SourceAddedAt = store.NullString(store.FormatTime(t))
@@ -128,23 +185,23 @@ func (f *fakeStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) e
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) {
return 0, nil
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (string, error) {
return "", nil
}
func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ int64) (*store.Recognition, error) {
func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ string) (*store.Recognition, error) {
return nil, nil
}
func (f *fakeStore) AddHint(_ context.Context, _ int64, _ string) error { return nil }
func (f *fakeStore) ListHints(_ context.Context, _ int64) ([]string, error) { return nil, nil }
func (f *fakeStore) SetOverride(_ context.Context, _ int64, _, _ string) error { return nil }
func (f *fakeStore) ListOverrides(_ context.Context, _ int64) (map[string]string, error) {
func (f *fakeStore) AddHint(_ context.Context, _ string, _ string) error { return nil }
func (f *fakeStore) ListHints(_ context.Context, _ string) ([]string, error) { return nil, nil }
func (f *fakeStore) SetOverride(_ context.Context, _ string, _, _ string) error { return nil }
func (f *fakeStore) ListOverrides(_ context.Context, _ string) (map[string]string, error) {
return nil, nil
}
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil }
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ int64, _ []string) error {
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ string, _ []string) error {
return nil
}
func (f *fakeStore) LatestBatchID(_ context.Context, _ int64) (string, error) { return "", nil }
func (f *fakeStore) LatestBatchID(_ context.Context, _ string) (string, error) { return "", nil }
func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.FileLink, error) {
return nil, nil
}
@@ -152,17 +209,18 @@ func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error {
func (f *fakeStore) CreateCandidates(_ context.Context, _ []store.MetadataCandidate) error {
return nil
}
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ int64) ([]store.MetadataCandidate, error) {
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ string) ([]store.MetadataCandidate, error) {
return nil, nil
}
func (f *fakeStore) GetCandidate(_ context.Context, _ int64) (*store.MetadataCandidate, error) {
func (f *fakeStore) GetCandidate(_ context.Context, _ string) (*store.MetadataCandidate, error) {
return nil, nil
}
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ int64) error { return nil }
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { return nil }
type fakeQbt struct {
torrents []qbt.Torrent
added []qbt.AddRequest
addErr error
files []qbt.File
}
@@ -184,6 +242,9 @@ func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, e
}
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
if f.addErr != nil {
return f.addErr
}
f.added = append(f.added, ar)
return nil
}
@@ -204,18 +265,28 @@ func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
}
func oneDownloading(infohash, createdAt string) *fakeStore {
return &fakeStore{downloads: map[int64]*store.Download{
1: {
ID: 1,
return &fakeStore{downloads: map[string]*store.Download{
"1": {
ID: "1",
State: store.StateDownloading,
SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash,
Infohash: store.NullString(infohash),
Infohashes: hashesOf("1", infohash),
CreatedAt: createdAt,
},
}}
}
// hashesOf — срез хешей загрузки для литералов фикстур.
func hashesOf(id string, hashes ...string) []store.Infohash {
out := make([]store.Infohash, 0, len(hashes))
for _, h := range hashes {
h = store.NormalizeHash(h)
out = append(out, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
}
return out
}
func TestPollTransitions(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
tests := []struct {
@@ -242,7 +313,7 @@ func TestPollTransitions(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := st.downloads[1].State; got != tc.want {
if got := st.downloads["1"].State; got != tc.want {
t.Errorf("state = %q, want %q", got, tc.want)
}
})
@@ -257,8 +328,8 @@ func TestPollMatchesByInfohashV2(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateCompleted {
t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateCompleted {
t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads["1"].State)
}
}
@@ -269,46 +340,93 @@ func TestPollIgnoresMissingTorrent(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateDownloading {
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateDownloading {
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads["1"].State)
}
}
func TestCancel(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
w := newTestWorker(st, &fakeQbt{})
if err := w.Cancel(context.Background(), 1); err != nil {
if err := w.Cancel(context.Background(), "1"); err != nil {
t.Fatalf("Cancel: %v", err)
}
if st.downloads[1].State != store.StateCancelled {
t.Errorf("state = %q, want cancelled", st.downloads[1].State)
if st.downloads["1"].State != store.StateCancelled {
t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
}
// Повторная отмена терминальной задачи — ошибка.
if err := w.Cancel(context.Background(), 1); err == nil {
if err := w.Cancel(context.Background(), "1"); err == nil {
t.Error("ожидалась ошибка при отмене терминальной задачи")
}
}
func TestRetry(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
st.downloads[1].State = store.StateStuck
st.downloads["1"].State = store.StateStuck
qb := &fakeQbt{}
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), 1); err != nil {
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
if st.downloads[1].State != store.StateDownloading {
t.Errorf("state = %q, want downloading", st.downloads[1].State)
if st.downloads["1"].State != store.StateDownloading {
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
}
if len(qb.added) != 1 {
t.Errorf("ожидалось повторное добавление в qBittorrent, got %d", len(qb.added))
}
}
// Retry при занятом хеше отклоняется ДО побочного эффекта: торрент не
// добавляется в qBittorrent повторно.
func TestRetryConflictNoAdd(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneDownloading(ih, timeRecent)
st.downloads["1"].State = store.StateFailed
st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
// Хешем владеет другая активная задача.
st.downloads["2"] = &store.Download{
ID: "2", State: store.StateDownloading, SourceType: store.SourceMagnet,
Infohashes: hashesOf("2", ih), CreatedAt: timeRecent,
}
qb := &fakeQbt{} // торрента в qBittorrent нет — без гарда был бы Add
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), "1"); !errors.Is(err, ErrConflict) {
t.Fatalf("ожидался ErrConflict, получили %v", err)
}
if len(qb.added) != 0 {
t.Errorf("торрент добавлен побочным эффектом отклонённого retry: %d Add", len(qb.added))
}
if st.downloads["1"].State != store.StateFailed {
t.Errorf("state = %s, want failed", st.downloads["1"].State)
}
}
// Если после активации повторный Add в qBittorrent упал — задача
// откатывается в прежнее состояние, а не остаётся «качающейся» без раздачи.
func TestRetryRollsBackOnAddFailure(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneDownloading(ih, timeRecent)
st.downloads["1"].State = store.StateFailed
st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
qb := &fakeQbt{addErr: fmt.Errorf("connection refused")}
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), "1"); err == nil {
t.Fatal("ожидалась ошибка Add")
}
if st.downloads["1"].State != store.StateFailed {
t.Errorf("state = %s, want failed (откат)", st.downloads["1"].State)
}
if st.downloads["1"].ErrorCode.String != "magnet_timeout" {
t.Errorf("error_code = %q, want magnet_timeout (восстановлен)", st.downloads["1"].ErrorCode.String)
}
}
func TestRetryRejectsActive(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
w := newTestWorker(st, &fakeQbt{})
if err := w.Retry(context.Background(), 1); err == nil {
if err := w.Retry(context.Background(), "1"); err == nil {
t.Error("retry активной (downloading) задачи должен отклоняться")
}
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-02
@@ -0,0 +1,240 @@
## Context
Идентичность сегодня: `download.id` — INTEGER AUTOINCREMENT (как и у всех
шести таблиц), дедуп — `idempotency_key UNIQUE` (= infohash у активных
задач). Ключ снимается при терминализации (`SetDownloadState`:
`idempotency_key = CASE WHEN terminal THEN NULL ELSE infohash END`) и
восстанавливается при возврате из терминала — так обеспечивается инвариант
«одна активная задача на infohash» при легальном повторном приёме того же
торрента после завершения. Поллинг сопоставляет раздачу по `hash`/
`infohash_v1`/`infohash_v2` с одним хранимым `download.infohash`.
Мотивация и разбор — в proposal и черновике
[logical-title-model §5.1](../../../docs/drafts/logical-title-model.md).
## Goals / Non-Goals
**Goals:**
- ULID как публичный стабильный ключ всех сущностей домена; единый формат id
в БД, URL и логах; grep по голому id находит всё.
- Множество инфохэшей загрузки (`download_infohash`) вместо одного столбца;
дедуп и сопоставление в поллинге — по любому из хешей.
- Упрощение механики активности: убрать снимаемый/восстанавливаемый
`idempotency_key`, активность выводится только из `state`.
- Конвенция «без числовых PK» для будущих таблиц.
**Non-Goals:**
- Сходимость папки, merge-раскладка, `state_transition`, сущность title —
отдельные change'и (этапность черновика).
- Оптимизация производительности БД (домашний масштаб).
- Совместимость со старыми числовыми id после миграции (старые URL в
истории Telegram/закладках, старые inline-кнопки бота) — не поддерживаем.
## Decisions
### D1. ULID, канонически lowercase
ULID (`github.com/oklog/ulid/v2`, чистый Go): 128 бит, сортируем по времени
(48 бит timestamp), 26 символов Crockford base32 — компактнее UUID, без
дефисов (grep/двойной клик в логах), глобально уникален across таблиц.
Альтернативы: UUIDv4 — не сортируется; UUIDv7 — эквивалент со статусом RFC,
выбрали бы при интеграции с внешней системой, ждущей UUID (таких нет);
xid/KSUID — менее распространены без выгоды.
Канонический вид — **lowercase** (читаемость; спека ULID case-insensitive
при декодировании). Единственная точка генерации — хелпер `internal/ident`:
`NewID()` (monotonic entropy, `strings.ToLower`), `Parse()` (нормализация
регистра + валидация). Id сущностей генерируют Create-методы `store`
(сейчас они живут на `LastInsertId` — все переводятся на `ident.NewID()`);
`apply_batch_id` генерирует воркер тем же хелпером. Все входные границы
(URL, формы — включая `candidate_id` в ревью) прогоняют id через `Parse`
до запроса к БД — сравнение в SQLite побайтовое.
### D2. Хранение: TEXT PK, обычные rowid-таблицы
TEXT(26), не BLOB(16) — читаемость в `sqlite3` CLI и логах дороже 10 байт.
`WITHOUT ROWID` не используем — выгода на нашем масштабе нулевая, а готчи
есть. `AUTOINCREMENT` исчезает из схемы полностью.
### D3. `download_infohash`: составной ключ, а НЕ `UNIQUE(infohash)`
```sql
CREATE TABLE download_infohash (
download_id TEXT NOT NULL REFERENCES download (id) ON DELETE CASCADE,
infohash TEXT NOT NULL, -- lowercase hex
kind TEXT NOT NULL, -- v1 | v2
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (infohash, download_id)
);
```
Черновик предлагал `UNIQUE(infohash)` — это **неверно**: спека
state-reconciliation гарантирует «повторный приём того же infohash после
терминала возможен», то есть один хеш легитимно принадлежит нескольким
загрузкам во времени. Глобальная уникальность действует только среди
**активных** загрузок, а это условие на `download.state` — в индекс SQLite
не выразить. PK `(infohash, download_id)` даёт и дедуп строк, и индекс для
поиска по хешу.
### D4. Инвариант «одна активная загрузка на infohash» — двумя атомарными операциями store
`idempotency_key` и его CASE-восстановление удаляются; активность — чисто
функция `state` (terminalStates).
**Критично:** сегодня финальный backstop инварианта — partial unique index
по `idempotency_key`, и на него опираются **пять** путей записи (комментарии
в коде прямо ссылаются на индекс): приём (`ingest`), adopt чужого торрента
(`worker/discover.go`), ручной `Retry` (`worker.go`), воскрешение сверкой
(`reconcile.go:reconcileOneRecovery`), `Relink` (`review.go`). Ни один из
них сейчас не делает check+write в одной транзакции — гонку закрывал индекс.
С удалением индекса **все пять** обязаны пройти через атомарные операции.
Вводим два guarded-метода `store`, каждый — одна write-транзакция
(`BEGIN IMMEDIATE`; SQLite сериализует писателей, поэтому check-then-write
внутри одной write-tx гонок не имеет):
- `CreateDownloadIfNoActive(d, hashes)` — проверка «нет активной загрузки с
любым из хешей» (join `download_infohash` × `state`) → вставка `download`
+ хешей; иначе возвращает существующую активную (семантика дедупа приёма),
дописав ей недостающие хеши из вызова (второй хеш гибрида не теряется).
Используют ingest и discover-adopt.
- `ActivateIfNoOtherActive(id, toState, …)` — проверка «никакая ДРУГАЯ
активная загрузка не владеет любым из хешей этой» (сама задача исключена
из выборки — stuck-задача при retry активна и не должна маскировать
чужого владельца) → переход состояния; иначе отказ. Используют Retry,
recovery-воскрешение, Relink; отказ — ДО побочных эффектов (Retry
активирует до повторного qbt.Add, при сбое Add откатывает состояние).
- `AddInfohashes(id, hashes)` — дозапись хешей (раскрытие гибрида) под тем
же гардом: хеш чужой активной задачи не дописывается (ErrInfohashTaken).
Механический бэкстоп вместо удалённого unique-индекса: `SetDownloadState`
отклоняет переход терминал→активное (предикат в UPDATE) — оживление идёт
только через `ActivateIfNoOtherActive`.
`FindActiveByInfohash`/`ExistsByInfohash` переезжают на join по
`download_infohash` и остаются для чтения (не как гард).
### D5. Накопление хешей из поллинга
Magnet-парсер извлекает btih (v1) **и** btmh (v2) гибридной ссылки
(`Info.Infohashes`, v1 первым) — приём записывает ВСЕ известные хеши,
`kind` — по длине hex: 40 = v1, 64 = v2 (не хардкодить v1). Когда
qBittorrent отдаёт торрент с заполненными `infohash_v1`/`infohash_v2`,
поллинг дописывает недостающие строки через guarded `AddInfohashes`.
Сборщик хешей торрента один — `torrentHashes`: поле `hash` qBittorrent
берётся только при пустых v1/v2 (старый API), потому что у v2-only раздач
это УСЕЧЁННЫЙ v2 (40 hex, по длине неотличим от v1) — его не храним, а
SourceRef усыновления строится из полноразмерного хеша (btih/btmh).
Сопоставление раздачи — по любому из хешей загрузки; live-карта воркера
уже ключуется всеми формами хеша торрента, модель с ней совместима.
### D6. Миграция: одна Go-миграция goose
Первая Go-миграция в проекте (до сих пор — только SQL-файлы из embed).
Механизм: goose поддерживает смешение — classic API (`goose.SetBaseFS` +
`goose.Up`) подхватывает и зарегистрированные Go-миграции. Регистрация —
`goose.AddMigrationContext` в `init()` пакета `store/migrations`, файл
`0006_*.go`; пакет становится Go-пакетом и должен быть импортирован из
`store.go` (иначе `init()` не выполнится). SQL не может генерить ULID —
поэтому Go.
Вся миграция — в одной транзакции goose. Важно: `PRAGMA foreign_keys=OFF`
внутри транзакции — тихий no-op в SQLite, а DSN включает FK на каждом
соединении, поэтому **работаем с включёнными FK** и соблюдаем порядок:
1. Прочитать строки старых таблиц **в порядке старого `id`** (хронология).
2. Сгенерить маппинг `old int id → ULID` для каждой таблицы:
**timestamp-часть — из `created_at` строки** (UTC в БД), entropy — через
`ulid.Monotonic`-reader. `created_at` имеет секундное разрешение и
дубли — норма (батч `file_link`): monotonic-инкремент entropy при равном
timestamp сохраняет относительный порядок старых id. Непарсибельный
`created_at` → время миграции.
3. Создать новые таблицы (`*_new`) **родители первыми**, дочерние — с
`REFERENCES` на `*_new`-родителей; заливать данные тоже родители-первыми
(FK включены — порядок обязателен). `download.infohash` разносится в
`download_infohash_new` (lowercase, `kind` по длине hex);
`idempotency_key` и `download.infohash` опускаются.
4. `DROP` старых таблиц **дети первыми**, затем `ALTER TABLE … RENAME`
(`*_new` → канонические имена; SQLite ≥ 3.25 переписывает `REFERENCES`
в ссылающихся таблицах при переименовании), пересоздать индексы.
5. Финальный `PRAGMA foreign_key_check` как самопроверка.
### D7. Границы: URL, ссылки, логи
- `httpapi`: парсинг `{id}` централизован в `pathID` — там `ident.Parse`
вместо `strconv.ParseInt`; невалидный id → 404 без похода в БД. Вне
`{id}`-роутов: `candidate_id` из формы ревью, сентинелы `downloadID > 0`
в `errBody`/`userErr` (со string — `!= ""`). **BREAKING для REST JSON**:
поле `id` в DTO меняет тип `number → string`.
- Telegram: ссылки бота ведут на `/review/{id}` (не только `/download/`),
callback-data содержит id (`parseCallback` через `strconv.ParseInt`,
сентинел `id == 0`, `pending map[int64]int64`) — всё переводится на
string/ULID. Старые сообщения: числовые URL отдадут 404, нажатие старой
inline-кнопки должно получать понятный ответ «кнопка устарела», а не
панику/тишину.
- Логи: поле `<entity>_id` у каждой сущности (`download_id`,
`recognition_id`, `batch_id`); scoped-логгер уже есть; grep по голому ULID
— штатный способ корреляции наравне с jq.
- Сортировки: списки сегодня сортируются `ORDER BY id DESC`
`COALESCE(source_added_at, created_at), id`) — с ULID это остаётся
корректным благодаря сортируемости и хронологическому бэкфиллу; менять
запросы не требуется.
### D8. Экспозиция множества хешей наружу
`download.infohash` читают не только дедуп и поллинг: карточка загрузки и
кнопка копирования (требование web-ui), REST DTO, live-лукап
(`Live(d.Infohash)`), scoped-логгеры воркера, поиск в списке
(`listWhere … infohash LIKE`). Решения:
- Модель `Download` дополняется срезом хешей, подгружаемым вместе с записью
(или методом `store`); карточка и REST показывают **все** хеши загрузки
(v1 и v2, каждый с копированием); REST-поле `infohash` заменяется на
`infohashes` (список).
- Live-лукап — по любому из хешей (live-карта воркера уже ключуется всеми
формами хеша торрента).
- Scoped-логгер кладёт в поле `infohash` первый известный хеш (для
корреляции этого достаточно — id теперь главный ключ поиска по логам).
- Поиск в списке — `EXISTS`-подзапрос по `download_infohash` вместо
`LIKE` по удаляемому столбцу.
### D9. Конвенция
Новый `docs/conventions/database.md`: PK — TEXT ULID, генерится приложением
через `internal/ident`; числовой AUTOINCREMENT не используем; у
деталей/связей допустим естественный/составной ключ; канонический вид id —
lowercase, нормализация на входных границах. Ссылки — из README конвенций и
CLAUDE.md. ER-схема `docs/specs/database.md` обновляется в том же change.
## Risks / Trade-offs
- [Гонка дедупа без UNIQUE-гарда] → check-then-insert строго в одной
write-транзакции (`BEGIN IMMEDIATE`), SQLite сериализует писателей.
- [Ошибка миграции портит данные] → миграция в транзакции goose
(SQLite умеет транзакционный DDL); перед деплоем — копия файла БД
(штатный бэкап на umbar пока не автоматизирован — сделать руками).
- [Старые URL в истории Telegram/закладках и старые inline-кнопки ломаются]
→ принято: домашний сервис, история коротка; редиректов со старых числовых
id не делаем; на устаревшую callback-data бот отвечает понятной ошибкой.
- [`created_at` непарсибелен] → fallback на время миграции, порядок ULID
внутри таблицы всё равно монотонен (entropy-инкремент).
- [Коллизия ULID] → 80 бит энтропии на миллисекунду, единственный генератор
в одном процессе — пренебрежимо.
- [Первая Go-миграция усложняет store/migrations] → цена принята: паттерн
понадобится и дальше (backfill-миграции), закладываем аккуратно.
## Migration Plan
1. Код + миграция в одном бинаре; goose прогоняет миграцию на старте, как
обычно.
2. Перед деплоем на umbar — ручная копия SQLite-файла (data-том).
3. Откат = восстановить копию файла + прежний бинарь (совместимость схем
вниз не поддерживаем).
## Open Questions
- Нет блокирующих. Мелочь на реализацию: `hint`/`override`/
`metadata_candidate` нигде не светятся наружу — их ULID нужны только для
единообразия и логов, отдельных требований не несут.
@@ -0,0 +1,69 @@
## Why
Идентичность в домене сегодня держится на двух хрупких вещах: загрузка
фактически идентифицируется инфохэшем (`idempotency_key`), хотя у одной
логической загрузки хешей несколько (v1/v2/гибрид, перезалив — другой хеш),
а первичные ключи всех таблиц — числовые автоинкременты, не уникальные между
таблицами и неудобные для корреляции в логах. Это фундамент (шаг 1 черновика
[logical-title-model](../../../docs/drafts/logical-title-model.md)) для
«второго сезона», «докачивания» и истории переходов; менять PK дешевле
сейчас, пока БД маленькая и ссылок на идентификатор немного.
## What Changes
- **ULID (канонически lowercase) как TEXT PK всех сущностей**: `download`,
`recognition`, `hint`, `override`, `metadata_candidate`, `file_link`.
Генерация — в приложении (`oklog/ulid`, monotonic entropy). **BREAKING**:
формат id меняется в URL (`/download/{id}`, `/review/{id}`), ссылках и
callback-data Telegram-бота, логах; в REST JSON поле `id` меняет тип
`number → string`, поле `infohash` заменяется списком `infohashes`.
- **Новая таблица `download_infohash`** (`download_id`, `infohash`,
`kind` v1|v2, составной PK `(infohash, download_id)` — один хеш легитимно
принадлежит нескольким загрузкам во времени) — множество хешей одной
загрузки; дедуп переезжает на проверку активности по этой таблице,
столбцы `download.idempotency_key` и `download.infohash` удаляются.
- **Поиск по любому из хешей** — при приёме (дедуп) и в поллинге qBittorrent.
- `apply_batch_id` генерируется как ULID (столбец уже TEXT).
- **Go-миграция goose**: backfill ULID существующим строкам с timestamp-частью
из `created_at` (сортировка id сохраняет хронологию), переписывание FK,
разнос текущего `infohash` в `download_infohash`.
- **Конвенция `docs/conventions/database.md`**: PK — TEXT ULID, генерится
приложением; числовой AUTOINCREMENT не используем; у деталей/связей
допустим естественный ключ.
- **Логи**: у каждой сущности поле `<entity>_id`; глобальная уникальность
ULID делает grep по голому id штатным способом корреляции; обновить
примеры в `docs/conventions/logging.md`.
- ER-схема `docs/specs/database.md` обновляется в этом же change.
## Capabilities
### New Capabilities
- `identity`: как система идентифицирует сущности домена — ULID-ключи и их
канонический вид (нормализация на входных границах), множество инфохэшей
загрузки, инвариант «одна активная загрузка на infohash» (дедуп при приёме),
сопоставление раздачи в поллинге по любому из хешей.
### Modified Capabilities
- `state-reconciliation`: требование «терминализация восстанавливает
`idempotency_key`» меняется — инвариант «одна активная задача на infohash»
обеспечивается проверкой активности по `download_infohash`, отдельный
снимаемый/восстанавливаемый ключ исчезает.
## Impact
- **Код**: `store` (типы id `int64 → string`, все запросы, миграция),
`ingest` (дедуп через `download_infohash`), `worker` (сопоставление в
поллинге по множеству хешей), `httpapi`/веб-UI (парсинг и валидация ULID в
`/download/{id}`, ссылки), Telegram-уведомления (ссылки на загрузку).
- **Зависимости**: + `github.com/oklog/ulid/v2` (чистый Go, CGO не нужен).
- **БД**: пересоздание всех шести таблиц (SQLite меняет PK только через
rebuild) одной миграцией; первая Go-миграция в проекте — goose до сих пор
использовался только с SQL-файлами, нужна регистрация Go-миграций.
- **Документация**: новая `docs/conventions/database.md`, правки
`docs/conventions/logging.md`, ER-схема `docs/specs/database.md`,
ссылка на новую конвенцию из `CLAUDE.md`/README конвенций.
- **Не меняется**: семантика дедупа (нашли активную загрузку по любому хешу →
та же загрузка), явные `ORDER BY created_at` в списках, инварианты
безопасности данных.
@@ -0,0 +1,156 @@
# identity — идентичность сущностей домена
Как система идентифицирует сущности: ULID-ключи и их канонический вид,
множество инфохэшей загрузки, дедупликация приёма, корреляция в логах.
## ADDED Requirements
### Requirement: ULID как первичный ключ сущностей
Каждая сущность домена SHALL иметь первичный ключ ULID — TEXT, 26 символов
Crockford base32, генерируемый приложением в момент создания записи через
единственную точку генерации (`internal/ident`). Сущности: `download`,
`recognition`, `hint`, `override`, `metadata_candidate`, `file_link`.
Канонический вид SHALL быть lowercase. Числовые AUTOINCREMENT-ключи в новых таблицах
использоваться SHALL NOT. Идентификатор партии раскладки (`apply_batch_id`)
SHALL генерироваться тем же способом.
#### Scenario: Создание загрузки
- **WHEN** принимается новая загрузка
- **THEN** её `id` — валидный ULID в lowercase
- **AND** `id` уникален глобально (не совпадает с id других сущностей)
#### Scenario: Хронологическая сортировка
- **GIVEN** две загрузки, созданные последовательно
- **WHEN** записи сортируются по `id` лексикографически
- **THEN** порядок совпадает с порядком создания
### Requirement: Нормализация и валидация id на входных границах
Внешние идентификаторы SHALL валидироваться как ULID и нормализоваться к
lowercase до обращения к хранилищу — это касается всех входных границ:
URL `/download/{id}`, параметры форм и команд. Синтаксически невалидный id SHALL обрабатываться как
несуществующая сущность (404 для страниц), без обращения к БД.
#### Scenario: Uppercase-вариант id в URL
- **GIVEN** существующая загрузка с id `01jz…` (lowercase)
- **WHEN** клиент открывает `/download/01JZ…` (uppercase)
- **THEN** открывается страница той же загрузки
#### Scenario: Мусор вместо id
- **WHEN** клиент открывает `/download/abc!!!`
- **THEN** ответ — 404, запрос к БД не выполняется
### Requirement: Множество инфохэшей загрузки
Загрузка SHALL иметь одну или более записей инфохэша (`download_infohash`:
`infohash` lowercase hex, `kind``v1`|`v2`). При приёме magnet-ссылки
SHALL записываться ВСЕ известные из неё хеши — гибридный magnet несёт и
btih (v1), и btmh (v2); `kind` определяется по длине hex (40 — `v1`, 64 —
`v2`). Когда qBittorrent сообщает для раздачи оба хеша (`infohash_v1`,
`infohash_v2`), система SHALL дописывать недостающие записи загрузке;
усечённый хеш v2-only раздачи (поле `hash` qBittorrent, 40 hex от v2)
записываться SHALL NOT. Сопоставление раздачи qBittorrent с загрузкой
(поллинг, discover) SHALL выполняться по любому из известных хешей. Один и
тот же infohash MAY принадлежать нескольким загрузкам во времени (повторный
приём после терминального состояния), но активной из них MUST быть не более
одной.
#### Scenario: Гибридный торрент раскрывает оба хеша
- **GIVEN** загрузка принята по magnet с v1-хешем
- **WHEN** qBittorrent отдаёт раздачу с заполненными `infohash_v1` и
`infohash_v2`
- **THEN** у загрузки появляются обе записи (`kind` = `v1` и `v2`)
#### Scenario: Сопоставление по v2-хешу
- **GIVEN** загрузка с записями v1- и v2-хешей
- **WHEN** поллинг находит раздачу, совпавшую только по v2-хешу
- **THEN** раздача сопоставляется с этой загрузкой
### Requirement: Дедупликация приёма по любому из хешей
При приёме система SHALL искать **активную** (нетерминальную) загрузку по
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
более одной активной загрузки на infohash». Отдельного снимаемого/
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
выводится только из `state`.
#### Scenario: Повторный приём при активной загрузке
- **GIVEN** активная загрузка с infohash `h`
- **WHEN** принимается magnet с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая
#### Scenario: Повторный приём после завершения
- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`)
- **WHEN** принимается magnet с тем же `h`
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
### Requirement: Атомарность возврата загрузки в активное состояние
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
возвращающем загрузку из терминального состояния в активное (ручной retry,
воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её
(приём, adopt чужой раздачи), что никакая другая активная загрузка не
владеет любым из хешей этой, и при владении SHALL отказывать в переходе,
сохраняя инвариант «не более одной активной загрузки на infohash».
Отказ SHALL происходить до побочных эффектов во внешних системах
(повторного добавления торрента в qBittorrent).
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие
гибридного торрента): хеш, которым владеет другая активная загрузка,
дописан быть SHALL NOT. Прямой перевод терминальной загрузки в активное
состояние в обход этой проверки SHALL отклоняться хранилищем (механический
бэкстоп вместо удалённого unique-индекса).
#### Scenario: Retry при занятом хеше
- **GIVEN** загрузка #1 в `failed` с хешем `h`, и другая активная загрузка
#2 с тем же `h`
- **WHEN** пользователь вызывает retry для #1
- **THEN** переход отклоняется с пояснением, #1 остаётся в `failed`
- **AND** активной по `h` остаётся #2
### Requirement: Корреляция сущностей в логах
Записи журнала, относящиеся к сущности, SHALL содержать её id в атрибуте
`<entity>_id` (`download_id`, `recognition_id`, `batch_id`, …); работа в
контексте загрузки ведётся через scoped-логгер с `download_id`. Благодаря
глобальной уникальности ULID поиск по значению id (grep/jq) SHALL находить
все записи журнала, относящиеся к сущности, независимо от имени поля.
#### Scenario: Путь загрузки по логам
- **GIVEN** загрузка прошла приём, распознавание и раскладку
- **WHEN** журнал фильтруется по значению её `id`
- **THEN** находятся записи всех этапов (ingest, recognition, file-layout)
### Requirement: Миграция существующих записей
Существующие записи SHALL получить ULID-идентификаторы одной миграцией с
сохранением всех связей (FK) и хронологии: timestamp-часть ULID SHALL
браться из `created_at` записи, чтобы лексикографический порядок новых id
соответствовал историческому порядку создания. Существующий
`download.infohash` SHALL быть перенесён в `download_infohash`
(нормализация к lowercase, `kind` по длине hex: 40 — `v1`, 64 — `v2`);
столбцы `download.infohash` и `download.idempotency_key` SHALL быть удалены.
#### Scenario: Связи и порядок после миграции
- **GIVEN** БД с загрузками, распознаваниями и файловыми ссылками на
числовых id
- **WHEN** миграция выполнена
- **THEN** все FK-связи сохранены (распознавания/ссылки указывают на те же
загрузки)
- **AND** порядок загрузок по `id` совпадает с порядком по `created_at`
- **AND** каждый прежний `infohash` представлен записью в
`download_infohash`
@@ -0,0 +1,113 @@
# state-reconciliation — дельта для ulid-identity
Механика идемпотентности меняется: снимаемый/восстанавливаемый
`idempotency_key` исчезает, инвариант «не более одной активной задачи на
infohash» обеспечивается проверкой активности по `download_infohash`
(см. capability `identity`).
## MODIFIED Requirements
### Requirement: Периодическая сверка состояния с реальностью
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
выводить состояние задачи из двух независимых признаков: присутствия
**источника** (раздача, совпавшая с **любым из известных хешей** загрузки в
`download_infohash`, в выдаче qBittorrent) и присутствия **цели** (см.
требование о владении целевым путём: существуют все ссылки последнего батча
со статусом раскладки, всё ещё принадлежащие этой загрузке).
Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`,
`target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT —
оно терминально. Активные (`downloading`/`recognizing`/`review`/`deferred`/
`linking`) и пользовательски-терминальные (`reverted`/`cancelled`) состояния
сверка по матрице трогать SHALL NOT.
**Восстановимые** `failed`/`stuck` (с `error_code` `magnet_timeout` или
`stalled` — задержки, вызванные нашей нетерпеливостью, а не реальной ошибкой)
сверка SHALL рассматривать отдельно — на предмет оживления источника (см.
требование о восстановлении зависшей загрузки), не по матрице «источник ×
цель». Прочие `failed` (например `qbit_error`) сверка трогать SHALL NOT.
Состояние SHALL переписываться только при его изменении (без записи и логов,
когда выведенное состояние совпадает с текущим).
#### Scenario: Источник и цель на месте — состояние не меняется
- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её
разложенные хардлинки существуют
- **THEN** задача остаётся в `done`
- **AND** запись состояния и лог перехода не выполняются
#### Scenario: Частичная пропажа цели считается отсутствием
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
#### Scenario: Задача в deleted сверкой не переоценивается
- **WHEN** задача находится в `deleted`
- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её
бывшему пути появился файл другой загрузки
#### Scenario: Провал по ошибке qBittorrent восстановлению не подлежит
- **WHEN** задача в `failed` с `error_code` `qbit_error`
- **THEN** сверка её не рассматривает и состояние не меняет
### Requirement: Восстановление зависшей загрузки при оживлении источника
Система SHALL возвращать в активный поток задачу, упавшую из-за нашей
нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её
источник в qBittorrent жив и продвинулся: переход выводится из текущего
состояния торрента так же, как при штатной сверке загрузки
(`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… →
`downloading`). Восстановление SHALL опираться на фактическое состояние
торрента в qBittorrent, а не на время с момента создания записи.
После возврата в любое нетерминальное состояние (`downloading` или
`completed`) повторный приём того же infohash SHALL снова дедуплицироваться
на эту задачу: активность задачи выводится только из её `state`, отдельный
восстанавливаемый ключ идемпотентности отсутствует. Если за время простоя в
`failed`/`stuck` тем же infohash (любым из хешей задачи) уже завладела
другая активная задача (новый приём, пока эта лежала упавшей), система
SHALL NOT воскрешать упавшую задачу и SHALL оставить её в `failed`/`stuck`,
сохраняя инвариант «не более одной активной задачи на infohash».
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом
прогрессирует в пределах страховочного таймаута, задача в `failed`/`stuck`
из-за него оказаться SHALL NOT (см. требование о терпеливости к долгим
метаданным в `docs/specs/workflow.md`).
#### Scenario: Метаданные пришли после magnet_timeout
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
в qBittorrent уже получил метаданные и качается (`downloading`)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача возвращается в `downloading`
- **AND** повторный приём того же infohash снова дедуплицируется на неё
#### Scenario: Торрент уже завершился, пока задача была в failed
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
в qBittorrent уже готов к раскладке (`uploading`/`stalledUP`)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача переходит в `completed` и продолжает обычный поток
(распознавание/раскладка)
#### Scenario: Источник так и не ожил — состояние не меняется
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
всё ещё висит в `metaDL` без метаданных (или отсутствует в qBittorrent)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача остаётся в `failed`
#### Scenario: infohash уже занят другой активной задачей
- **GIVEN** задача #1 в `failed`/`magnet_timeout`, а тем же infohash уже
владеет другая активная задача #2 (приём повторили, пока #1 лежала упавшей)
- **WHEN** источник ожил (торрент получил метаданные или готов) и сверка
пытается воскресить #1
- **THEN** #1 остаётся в `failed` (восстановление не выполняется)
- **AND** активной по этому infohash остаётся #2
@@ -0,0 +1,81 @@
## 1. Фундамент: пакет ident
- [x] 1.1 Добавить зависимость `github.com/oklog/ulid/v2`; пакет
`internal/ident`: `NewID()` (lowercase, monotonic entropy,
потокобезопасно), `NewIDAt(t time.Time)` (для миграции/бэкфилла),
`Parse(s)` (нормализация регистра + валидация); тесты
(lowercase, сортируемость, отказ на мусоре)
## 2. Схема и миграция
- [x] 2.1 Механизм Go-миграций goose в `internal/store/migrations`
(регистрация через `goose.AddMigrationContext`, совместный прогон с
embed SQL-миграциями)
- [x] 2.2 Миграция 0006: новые таблицы с TEXT ULID PK (все шесть), перенос
данных с маппингом `int → ULID` (timestamp из `created_at`, fallback —
время миграции), перенос `download.infohash``download_infohash`
(lowercase, `kind` по длине hex), удаление `download.infohash` и
`download.idempotency_key`, пересоздание индексов
- [x] 2.3 Тест миграции на фикстурной БД: FK-связи сохранены, порядок по
`id` = порядок по `created_at`, хеши разнесены, `idempotency_key`
отсутствует
## 3. Store
- [x] 3.1 Типы id `int64 → string` во всех структурах и методах `store`
(download, recognition, hint, override, metadata_candidate,
file_link, list); генерация ULID через `ident.NewID()` во ВСЕХ
Create-методах (вместо `LastInsertId`)
- [x] 3.2 Guarded-методы инварианта (design D4):
`CreateDownloadIfNoActive` и `ActivateIfNoOtherActive`, каждый — одна
write-транзакция; `FindActiveByInfohash`/`ExistsByInfohash` join'ом
по любому хешу (для чтения); убрать CASE-восстановление
`idempotency_key` из `SetDownloadState`
- [x] 3.3 Методы хешей: добавить недостающие хеши загрузке
(INSERT OR IGNORE), получать хеши вместе с Download (срез в модели)
- [x] 3.4 Поиск в списке (`listWhere`): `EXISTS`-подзапрос по
`download_infohash` вместо `LIKE` по удаляемому `download.infohash`
## 4. Ядро и воркер
- [x] 4.1 Приём (`ingest`) и discover-adopt — через
`CreateDownloadIfNoActive`; хеш из magnet (btih ИЛИ btmh, `kind` по
длине hex) пишется в той же транзакции; сигнатуры
`Result.DownloadID`, `notifyFailed`, `Notifier.Notify`,
`failNotified` — на string
- [x] 4.2 Поллинг/сверка: сопоставление раздачи по любому из хешей загрузки
(Poll, desync, recovery, preflight); дописывание недостающих v1/v2,
когда qBittorrent отдаёт оба; scoped-логгеры — первый известный хеш
- [x] 4.3 Retry, recovery-воскрешение и Relink — через
`ActivateIfNoOtherActive` (сейчас гонку закрывал unique-индекс —
см. design D4); понятная ошибка при занятом хеше
- [x] 4.4 `apply_batch_id` генерировать через `ident.NewID()`
## 5. Внешние границы
- [x] 5.1 `httpapi`: `ident.Parse` в `pathID` (невалидный → 404 без похода
в БД) и для `candidate_id` из формы ревью; сентинелы
`downloadID > 0 → != ""`; REST DTO: `id` string, `infohash`
список `infohashes`; карточка показывает все хеши с копированием;
live-лукап по любому хешу; проверить шаблоны и ссылки
- [x] 5.2 Telegram (`tgbot`): `parseCallback` и callback-data на string-id,
`pending map[int64]int64 → map[int64]string`, сентинел `id == 0 →
== ""`, ссылки `/review/{id}`; понятный ответ на устаревшую
callback-data со старым числовым id
## 6. Логи и документация
- [x] 6.1 Атрибуты `<entity>_id` в логах: `recognition_id` у попыток
распознавания, `batch_id` у раскладки; сверить с scoped-логгером
- [x] 6.2 `docs/conventions/logging.md`: примеры id в формате ULID, grep по
голому id как штатная корреляция
- [x] 6.3 Новая `docs/conventions/database.md` (TEXT ULID PK, без
AUTOINCREMENT, естественные ключи у деталей, lowercase + нормализация);
ссылки из `docs/conventions/README.md` и `CLAUDE.md`
- [x] 6.4 ER-схема `docs/specs/database.md`: ULID PK, `download_infohash`,
удалённые столбцы
## 7. Проверка
- [x] 7.1 `task test` и `task lint` зелёные; ручной прогон: приём magnet →
дедуп повторного приёма → страница `/download/{id}` с ULID в URL
+161
View File
@@ -0,0 +1,161 @@
# identity Specification
## Purpose
Как система идентифицирует сущности домена: ULID-ключи (канонический
lowercase-вид, нормализация и валидация на входных границах), множество
инфохэшей загрузки (`download_infohash`), инвариант «не более одной
активной загрузки на infohash» (дедупликация приёма, атомарный возврат в
активное состояние), корреляция сущностей в логах по id.
## Requirements
### Requirement: ULID как первичный ключ сущностей
Каждая сущность домена SHALL иметь первичный ключ ULID — TEXT, 26 символов
Crockford base32, генерируемый приложением в момент создания записи через
единственную точку генерации (`internal/ident`). Сущности: `download`,
`recognition`, `hint`, `override`, `metadata_candidate`, `file_link`.
Канонический вид SHALL быть lowercase. Числовые AUTOINCREMENT-ключи в новых таблицах
использоваться SHALL NOT. Идентификатор партии раскладки (`apply_batch_id`)
SHALL генерироваться тем же способом.
#### Scenario: Создание загрузки
- **WHEN** принимается новая загрузка
- **THEN** её `id` — валидный ULID в lowercase
- **AND** `id` уникален глобально (не совпадает с id других сущностей)
#### Scenario: Хронологическая сортировка
- **GIVEN** две загрузки, созданные последовательно
- **WHEN** записи сортируются по `id` лексикографически
- **THEN** порядок совпадает с порядком создания
### Requirement: Нормализация и валидация id на входных границах
Внешние идентификаторы SHALL валидироваться как ULID и нормализоваться к
lowercase до обращения к хранилищу — это касается всех входных границ:
URL `/download/{id}`, параметры форм и команд. Синтаксически невалидный id SHALL обрабатываться как
несуществующая сущность (404 для страниц), без обращения к БД.
#### Scenario: Uppercase-вариант id в URL
- **GIVEN** существующая загрузка с id `01jz…` (lowercase)
- **WHEN** клиент открывает `/download/01JZ…` (uppercase)
- **THEN** открывается страница той же загрузки
#### Scenario: Мусор вместо id
- **WHEN** клиент открывает `/download/abc!!!`
- **THEN** ответ — 404, запрос к БД не выполняется
### Requirement: Множество инфохэшей загрузки
Загрузка SHALL иметь одну или более записей инфохэша (`download_infohash`:
`infohash` lowercase hex, `kind``v1`|`v2`). При приёме magnet-ссылки
SHALL записываться ВСЕ известные из неё хеши — гибридный magnet несёт и
btih (v1), и btmh (v2); `kind` определяется по длине hex (40 — `v1`, 64 —
`v2`). Когда qBittorrent сообщает для раздачи оба хеша (`infohash_v1`,
`infohash_v2`), система SHALL дописывать недостающие записи загрузке;
усечённый хеш v2-only раздачи (поле `hash` qBittorrent, 40 hex от v2)
записываться SHALL NOT. Сопоставление раздачи qBittorrent с загрузкой
(поллинг, discover) SHALL выполняться по любому из известных хешей. Один и
тот же infohash MAY принадлежать нескольким загрузкам во времени (повторный
приём после терминального состояния), но активной из них MUST быть не более
одной.
#### Scenario: Гибридный торрент раскрывает оба хеша
- **GIVEN** загрузка принята по magnet с v1-хешем
- **WHEN** qBittorrent отдаёт раздачу с заполненными `infohash_v1` и
`infohash_v2`
- **THEN** у загрузки появляются обе записи (`kind` = `v1` и `v2`)
#### Scenario: Сопоставление по v2-хешу
- **GIVEN** загрузка с записями v1- и v2-хешей
- **WHEN** поллинг находит раздачу, совпавшую только по v2-хешу
- **THEN** раздача сопоставляется с этой загрузкой
### Requirement: Дедупликация приёма по любому из хешей
При приёме система SHALL искать **активную** (нетерминальную) загрузку по
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
более одной активной загрузки на infohash». Отдельного снимаемого/
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
выводится только из `state`.
#### Scenario: Повторный приём при активной загрузке
- **GIVEN** активная загрузка с infohash `h`
- **WHEN** принимается magnet с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая
#### Scenario: Повторный приём после завершения
- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`)
- **WHEN** принимается magnet с тем же `h`
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
### Requirement: Атомарность возврата загрузки в активное состояние
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
возвращающем загрузку из терминального состояния в активное (ручной retry,
воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её
(приём, adopt чужой раздачи), что никакая другая активная загрузка не
владеет любым из хешей этой, и при владении SHALL отказывать в переходе,
сохраняя инвариант «не более одной активной загрузки на infohash».
Отказ SHALL происходить до побочных эффектов во внешних системах
(повторного добавления торрента в qBittorrent).
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие
гибридного торрента): хеш, которым владеет другая активная загрузка,
дописан быть SHALL NOT. Прямой перевод терминальной загрузки в активное
состояние в обход этой проверки SHALL отклоняться хранилищем (механический
бэкстоп вместо удалённого unique-индекса).
#### Scenario: Retry при занятом хеше
- **GIVEN** загрузка #1 в `failed` с хешем `h`, и другая активная загрузка
#2 с тем же `h`
- **WHEN** пользователь вызывает retry для #1
- **THEN** переход отклоняется с пояснением, #1 остаётся в `failed`
- **AND** активной по `h` остаётся #2
### Requirement: Корреляция сущностей в логах
Записи журнала, относящиеся к сущности, SHALL содержать её id в атрибуте
`<entity>_id` (`download_id`, `recognition_id`, `batch_id`, …); работа в
контексте загрузки ведётся через scoped-логгер с `download_id`. Благодаря
глобальной уникальности ULID поиск по значению id (grep/jq) SHALL находить
все записи журнала, относящиеся к сущности, независимо от имени поля.
#### Scenario: Путь загрузки по логам
- **GIVEN** загрузка прошла приём, распознавание и раскладку
- **WHEN** журнал фильтруется по значению её `id`
- **THEN** находятся записи всех этапов (ingest, recognition, file-layout)
### Requirement: Миграция существующих записей
Существующие записи SHALL получить ULID-идентификаторы одной миграцией с
сохранением всех связей (FK) и хронологии: timestamp-часть ULID SHALL
браться из `created_at` записи, чтобы лексикографический порядок новых id
соответствовал историческому порядку создания. Существующий
`download.infohash` SHALL быть перенесён в `download_infohash`
(нормализация к lowercase, `kind` по длине hex: 40 — `v1`, 64 — `v2`);
столбцы `download.infohash` и `download.idempotency_key` SHALL быть удалены.
#### Scenario: Связи и порядок после миграции
- **GIVEN** БД с загрузками, распознаваниями и файловыми ссылками на
числовых id
- **WHEN** миграция выполнена
- **THEN** все FK-связи сохранены (распознавания/ссылки указывают на те же
загрузки)
- **AND** порядок загрузок по `id` совпадает с порядком по `created_at`
- **AND** каждый прежний `infohash` представлен записью в
`download_infohash`
+13 -13
View File
@@ -17,10 +17,10 @@ qBittorrent. Capability описывает периодическую и при
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
ожидаются разложенные файлы, с фактом на файловой системе и в 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
+1 -1
View File
@@ -94,7 +94,7 @@
<div class="section-head"><h2>Информация о торренте</h2><span class="hint">сырой источник и infohash</span></div>
<dl class="kv">
<dt>Тип источника</dt><dd>{{.SourceType}}</dd>
{{if .Infohash}}<dt>infohash</dt><dd class="mono">{{.Infohash}}</dd>{{end}}
{{range .Infohashes}}<dt>infohash</dt><dd class="mono">{{.}}</dd>{{end}}
</dl>
{{if .SourceFull}}
<div class="src-block">