Compare commits
4
Commits
02e24799ad
...
783664622c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
783664622c
|
||
|
|
4fc8c41b3a
|
||
|
|
cc7e51b3a4
|
||
|
|
f9d7fd9216
|
@@ -124,6 +124,9 @@ Module path — `git.vakhrushev.me/av/jellybit`. Go 1.26, `CGO_ENABLED=0`.
|
|||||||
файл (`config.toml` не коммитится, `0600`), не в env; валидация на старте:
|
файл (`config.toml` не коммитится, `0600`), не в env; валидация на старте:
|
||||||
[docs/conventions/config.md](docs/conventions/config.md).
|
[docs/conventions/config.md](docs/conventions/config.md).
|
||||||
- Время — всегда с явным TZ (сервер в `Europe/Moscow`).
|
- Время — всегда с явным TZ (сервер в `Europe/Moscow`).
|
||||||
|
- Миграции БД (goose, `internal/store/migrations`) — при изменении структуры
|
||||||
|
(таблица/столбец/индекс/связь) в том же change обновляем ER-схему
|
||||||
|
[docs/specs/database.md](docs/specs/database.md).
|
||||||
|
|
||||||
Кросс-каттинг конвенции (как пишем код, а не что система делает) живут в
|
Кросс-каттинг конвенции (как пишем код, а не что система делает) живут в
|
||||||
[docs/conventions/](docs/conventions/README.md) и не переносятся в OpenSpec.
|
[docs/conventions/](docs/conventions/README.md) и не переносятся в OpenSpec.
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ func runServe(args []string) error {
|
|||||||
PollInterval: cfg.Worker.PollInterval.Std(),
|
PollInterval: cfg.Worker.PollInterval.Std(),
|
||||||
StuckAfter: cfg.Worker.StuckAfter.Std(),
|
StuckAfter: cfg.Worker.StuckAfter.Std(),
|
||||||
MagnetTimeout: cfg.Worker.MagnetTimeout.Std(),
|
MagnetTimeout: cfg.Worker.MagnetTimeout.Std(),
|
||||||
|
SourceMissingThreshold: cfg.Worker.SourceMissingThreshold,
|
||||||
}, logger)
|
}, logger)
|
||||||
|
|
||||||
// Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin
|
// Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin
|
||||||
@@ -270,6 +271,7 @@ func metadataProviders(cfg *config.Config, logger *slog.Logger) ([]metadata.Prov
|
|||||||
APIKey: cfg.Metadata.TMDB.APIKey,
|
APIKey: cfg.Metadata.TMDB.APIKey,
|
||||||
Proxy: cfg.Metadata.TMDB.Proxy,
|
Proxy: cfg.Metadata.TMDB.Proxy,
|
||||||
Timeout: cfg.Metadata.TMDB.Timeout.Std(),
|
Timeout: cfg.Metadata.TMDB.Timeout.Std(),
|
||||||
|
Language: cfg.Metadata.TMDB.Language,
|
||||||
}, logger)
|
}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("tmdb provider: %w", err)
|
return nil, fmt.Errorf("tmdb provider: %w", err)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ enabled = false # включить провайдера TMD
|
|||||||
api_key = "" # секрет: ключ TMDB; обязателен, если enabled (заполняет деплой)
|
api_key = "" # секрет: ключ TMDB; обязателен, если enabled (заполняет деплой)
|
||||||
proxy = "" # опц. HTTP-прокси; пусто = без прокси
|
proxy = "" # опц. HTTP-прокси; пусто = без прокси
|
||||||
timeout = "10s" # таймаут запроса к TMDB; Go-duration (s/m/h)
|
timeout = "10s" # таймаут запроса к TMDB; Go-duration (s/m/h)
|
||||||
|
language = "ru-RU" # локаль названий в ответе TMDB; пусто = ru-RU
|
||||||
|
|
||||||
[metadata.tvdb]
|
[metadata.tvdb]
|
||||||
enabled = false # включить провайдера TVDB
|
enabled = false # включить провайдера TVDB
|
||||||
@@ -62,6 +63,7 @@ timeout = "10s" # таймаут запроса к Jellyfin
|
|||||||
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
|
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
|
||||||
stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration
|
stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration
|
||||||
magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration
|
magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration
|
||||||
|
source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс)
|
||||||
|
|
||||||
[recognition]
|
[recognition]
|
||||||
auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0
|
auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
[worker]
|
[worker]
|
||||||
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
|
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
|
||||||
magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration
|
magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration
|
||||||
|
source_missing_threshold = 3 # тиков сверки без раздачи, чтобы счесть источник удалённым
|
||||||
|
|
||||||
[recognition]
|
[recognition]
|
||||||
auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0
|
auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ reject / defer / undo) — команды к `worker`:
|
|||||||
|
|
||||||
## Хранилище
|
## Хранилище
|
||||||
|
|
||||||
SQLite. Схема покрывает приём, цикл ревью и откат:
|
SQLite. Полная схема (таблицы, поля, связи) — [database.md](database.md),
|
||||||
|
поддерживается вместе с миграциями. Схема покрывает приём, цикл ревью и
|
||||||
|
откат:
|
||||||
|
|
||||||
- `download` — `id`, тип и значение источника, контекст, `infohash`,
|
- `download` — `id`, тип и значение источника, контекст, `infohash`,
|
||||||
`idempotency_key`, состояние, `error_code`/`error_msg`, тайминги.
|
`idempotency_key`, состояние, `error_code`/`error_msg`, тайминги.
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Схема базы данных
|
||||||
|
|
||||||
|
Актуальная схема SQLite-хранилища: таблицы, поля и связи. Это **живой**
|
||||||
|
документ — его поддерживаем в соответствии с миграциями.
|
||||||
|
|
||||||
|
> **Поддержка вместе с миграциями.** Источник истины по схеме —
|
||||||
|
> `internal/store/migrations/*.sql` (goose). При **каждой** новой миграции,
|
||||||
|
> меняющей структуру (таблица/столбец/индекс/связь), обновляем эту диаграмму
|
||||||
|
> в том же change. Расхождение схемы с миграциями считаем багом
|
||||||
|
> документации.
|
||||||
|
>
|
||||||
|
> Состояние на: миграции `0001_init`, `0002_recognition_plan`,
|
||||||
|
> `0003_source_miss_count`.
|
||||||
|
|
||||||
|
Назначение таблиц и почему так — [architecture.md](architecture.md) →
|
||||||
|
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.md).
|
||||||
|
|
||||||
|
## ER-диаграмма
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
download ||--o{ recognition : "распознавания"
|
||||||
|
download ||--o{ hint : "подсказки"
|
||||||
|
download ||--o{ override : "ручные правки"
|
||||||
|
download ||--o{ file_link : "хардлинки"
|
||||||
|
recognition ||--o{ metadata_candidate : "кандидаты базы"
|
||||||
|
|
||||||
|
download {
|
||||||
|
INTEGER id PK "AUTOINCREMENT"
|
||||||
|
TEXT source_type "NOT NULL; magnet|torrent|url"
|
||||||
|
TEXT source_ref "NOT NULL; magnet/url/путь"
|
||||||
|
TEXT context "NOT NULL DEFAULT ''"
|
||||||
|
TEXT infohash "nullable; может появиться позже приёма"
|
||||||
|
TEXT idempotency_key "nullable; UNIQUE если NOT NULL"
|
||||||
|
TEXT state "NOT NULL; см. workflow.md"
|
||||||
|
TEXT error_code "nullable"
|
||||||
|
TEXT error_msg "nullable"
|
||||||
|
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
|
||||||
|
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
||||||
|
TEXT updated_at "NOT NULL DEFAULT datetime('now')"
|
||||||
|
}
|
||||||
|
|
||||||
|
recognition {
|
||||||
|
INTEGER id PK "AUTOINCREMENT"
|
||||||
|
INTEGER 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"
|
||||||
|
TEXT title "nullable"
|
||||||
|
TEXT original_title "nullable"
|
||||||
|
INTEGER year "nullable"
|
||||||
|
TEXT provider "nullable; tmdb|tvdb|tvmaze|none"
|
||||||
|
TEXT provider_id "nullable"
|
||||||
|
REAL confidence "nullable"
|
||||||
|
TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины не-авто"
|
||||||
|
TEXT raw_llm "nullable; сырой ответ LLM"
|
||||||
|
TEXT plan "nullable; JSON recognize.Plan (миграция 0002)"
|
||||||
|
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
||||||
|
}
|
||||||
|
|
||||||
|
hint {
|
||||||
|
INTEGER id PK "AUTOINCREMENT"
|
||||||
|
INTEGER 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 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 provider "NOT NULL"
|
||||||
|
TEXT provider_id "NOT NULL"
|
||||||
|
TEXT title "nullable"
|
||||||
|
INTEGER year "nullable"
|
||||||
|
INTEGER chosen "NOT NULL DEFAULT 0; 0/1"
|
||||||
|
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
||||||
|
}
|
||||||
|
|
||||||
|
file_link {
|
||||||
|
INTEGER id PK "AUTOINCREMENT"
|
||||||
|
INTEGER 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; целевой хардлинк"
|
||||||
|
TEXT kind "NOT NULL; video|subtitle|..."
|
||||||
|
TEXT status "NOT NULL; linked|..."
|
||||||
|
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Связи и кардинальность
|
||||||
|
|
||||||
|
- `download` 1 — N `recognition` / `hint` / `override` / `file_link`;
|
||||||
|
`recognition` 1 — N `metadata_candidate`. Все дочерние — с
|
||||||
|
`ON DELETE CASCADE`: удаление загрузки уносит её распознавания, подсказки,
|
||||||
|
правки и ссылки.
|
||||||
|
- `download` ↔ `file_link` — один источник (раздача) ко многим разложенным
|
||||||
|
файлам; внутри строки `file_link` связь `src_path → dst_path` — 1:1. Не
|
||||||
|
каждый файл раздачи попадает в `file_link` (только распознанные медиа и
|
||||||
|
субтитры); ссылки могут накапливаться несколькими `apply_batch_id`.
|
||||||
|
|
||||||
|
## Индексы и ограничения
|
||||||
|
|
||||||
|
- `download`: `UNIQUE(idempotency_key) WHERE idempotency_key IS NOT NULL`;
|
||||||
|
индекс по `state`.
|
||||||
|
- `recognition`: индекс по `download_id`.
|
||||||
|
- `override`: `UNIQUE(download_id, field)`.
|
||||||
|
- `metadata_candidate`: индекс по `recognition_id`.
|
||||||
|
- `file_link`: индексы по `download_id` и по `apply_batch_id`.
|
||||||
|
|
||||||
|
> Enum-поля (`source_type`, `state`, `provider`, `kind`, `status`, флаги
|
||||||
|
> `0/1`) на уровне SQLite — обычный `TEXT`/`INTEGER` без `CHECK`; допустимые
|
||||||
|
> значения держит код (`internal/store`).
|
||||||
@@ -57,6 +57,18 @@ inode общий — диск не дублируется.
|
|||||||
поддержки жёстких ссылок), `layout` не падает, а копирует файл с
|
поддержки жёстких ссылок), `layout` не падает, а копирует файл с
|
||||||
предупреждением в лог — см. architecture.md → «Раскладка файлов».
|
предупреждением в лог — см. architecture.md → «Раскладка файлов».
|
||||||
|
|
||||||
|
## Безопасный undo (не снимать последнюю копию)
|
||||||
|
|
||||||
|
`Undo` снимает **лишний** хардлинк, а не единственный файл. Перед удалением
|
||||||
|
батча `layout` проверяет каждую цель: если исходный файл уже не существует
|
||||||
|
**или** у цели не осталось других жёстких ссылок (`nlink <= 1`), это —
|
||||||
|
последняя копия данных, и весь `Undo` отклоняется целиком (ошибка
|
||||||
|
`ErrLastCopy`), не сняв ни одной ссылки (частичный откат тоже стёр бы часть
|
||||||
|
данных). Так нарушенный инвариант «источник неприкосновенен» (источник
|
||||||
|
удалён вручную) не приводит к потере данных. Отсутствующую цель `Undo`
|
||||||
|
пропускает как уже снятую (идемпотентность). Связь с состояниями
|
||||||
|
рассинхрона — [workflow.md](workflow.md) → «Сверка с реальностью».
|
||||||
|
|
||||||
## Крайние случаи
|
## Крайние случаи
|
||||||
|
|
||||||
- **Многофайловый фильм** (части) — стэкинг по точному токену Jellyfin
|
- **Многофайловый фильм** (части) — стэкинг по точному токену Jellyfin
|
||||||
|
|||||||
@@ -38,6 +38,20 @@
|
|||||||
названию+году, берём официальный id и каноническое имя, собираем
|
названию+году, берём официальный id и каноническое имя, собираем
|
||||||
кандидатов. TVMaze — без ключа, только сериалы; внешний id
|
кандидатов. TVMaze — без ключа, только сериалы; внешний id
|
||||||
(TVDB/IMDb) из `externals` идёт в имя папки.
|
(TVDB/IMDb) из `externals` идёт в имя папки.
|
||||||
|
- **Поиск по нескольким названиям** в порядке убывания силы ключа:
|
||||||
|
`original_title` → локализованное `title` → `provider_hint`. Базы
|
||||||
|
индексированы прежде всего по оригинальным названиям, поэтому
|
||||||
|
оригинал — первым; останавливаемся, как только очередной ключ дал
|
||||||
|
единичный сильный матч. Пустые и нормализованно-дублирующие ключи
|
||||||
|
пропускаем (русский фильм, где оригинал = локализованное, дёргает
|
||||||
|
базу один раз). Кандидатов для review копим из всех заходов.
|
||||||
|
- **Локаль TMDB:** запрос передаёт `language` (по умолчанию `ru-RU`,
|
||||||
|
настраивается `[metadata.tmdb].language`). Влияет только на
|
||||||
|
локализованный `Title`/`Name`; `original_title`/`original_name`
|
||||||
|
остаётся на языке оригинала, поэтому оригинальная сторона сравнения
|
||||||
|
не страдает, а русская — сходится.
|
||||||
|
- **Нормализация названий** при сравнении сводит `ё`→`е` («Тёмный» и
|
||||||
|
«Темный» — одно название).
|
||||||
4. **Оценка уверенности** и решение: авто или review.
|
4. **Оценка уверенности** и решение: авто или review.
|
||||||
|
|
||||||
## Структура ответа LLM (предварительная)
|
## Структура ответа LLM (предварительная)
|
||||||
@@ -45,7 +59,9 @@
|
|||||||
```
|
```
|
||||||
type movie | series
|
type movie | series
|
||||||
title каноническое название
|
title каноническое название
|
||||||
original_title оригинальное название (если есть)
|
original_title оригинальное (обычно англ.) название — заполняется всегда:
|
||||||
|
нет отдельного / российский контент → дублирует title;
|
||||||
|
при неуверенности дублируем, а не выдумываем
|
||||||
year год
|
year год
|
||||||
provider_hint строка для поиска в базе (НЕ итоговый id)
|
provider_hint строка для поиска в базе (НЕ итоговый id)
|
||||||
files[] { src, role: main|episode|subtitle|extra|sample|ignore,
|
files[] { src, role: main|episode|subtitle|extra|sample|ignore,
|
||||||
|
|||||||
@@ -39,9 +39,19 @@ stateDiagram-v2
|
|||||||
stuck --> downloading: Retry
|
stuck --> downloading: Retry
|
||||||
failed --> downloading: Retry
|
failed --> downloading: Retry
|
||||||
|
|
||||||
|
done --> target_missing: сверка — цель удалена
|
||||||
|
done --> orphaned: сверка — источник пропал
|
||||||
|
target_missing --> recognizing: Привязать заново
|
||||||
|
target_missing --> orphaned: источник тоже пропал
|
||||||
|
target_missing --> deleted: источник тоже пропал
|
||||||
|
orphaned --> deleted: цель тоже удалена
|
||||||
|
target_missing --> done: healing (цель вернулась)
|
||||||
|
orphaned --> done: healing (источник вернулся)
|
||||||
|
|
||||||
done --> [*]
|
done --> [*]
|
||||||
cancelled --> [*]
|
cancelled --> [*]
|
||||||
reverted --> [*]
|
reverted --> [*]
|
||||||
|
deleted --> [*]
|
||||||
|
|
||||||
note right of cancelled
|
note right of cancelled
|
||||||
«Отклонить» доступно из любого
|
«Отклонить» доступно из любого
|
||||||
@@ -85,6 +95,30 @@ stateDiagram-v2
|
|||||||
(авто-раскладку не делаем) и требует, чтобы раздача всё ещё была в
|
(авто-раскладку не делаем) и требует, чтобы раздача всё ещё была в
|
||||||
qBittorrent.
|
qBittorrent.
|
||||||
|
|
||||||
|
## Сверка с реальностью (рассинхрон)
|
||||||
|
|
||||||
|
Состояние в БД может разойтись с диском при **ручном** удалении: раздачу
|
||||||
|
стирают из qBittorrent (источник) или файлы убирают из Jellyfin (целевые
|
||||||
|
хардлинки). `worker` периодически сверяет уже разложенные задачи с фактом по
|
||||||
|
двумерной матрице «источник × цель» (источник = раздача в qBittorrent,
|
||||||
|
цель = разложенные хардлинки на ФС) и выводит состояние:
|
||||||
|
|
||||||
|
- **target_missing** — источник на месте, цель удалена. Доступна команда
|
||||||
|
«Привязать заново» (`→ recognizing`); авто-действий нет.
|
||||||
|
- **orphaned** — источник пропал, цель (последняя копия данных) на месте.
|
||||||
|
Команд вперёд нет; `Undo` запрещён (снял бы единственную копию).
|
||||||
|
- **deleted** — нет ни источника, ни цели; терминально.
|
||||||
|
|
||||||
|
Сверка трогает только `done`/`target_missing`/`orphaned`/`deleted` —
|
||||||
|
активные и пользовательски-терминальные (`reverted`/`cancelled`/`failed`/
|
||||||
|
`stuck`) состояния не задевает. Реальность «лечится» сама: при возврате
|
||||||
|
источника/цели задача переходит обратно (вплоть до `done`). Пропажа
|
||||||
|
**источника** дебаунсится (`[worker].source_missing_threshold` подряд идущих
|
||||||
|
тиков), пропажа цели проверяется немедленно (локальная ФС надёжна). Команды,
|
||||||
|
которым нужен источник (relink/распознать/применить/undo), проверяют его
|
||||||
|
**синхронно перед действием** и не полагаются на фоновую сверку. Полные
|
||||||
|
требования — `openspec/specs/state-reconciliation/`.
|
||||||
|
|
||||||
Все переходы и команды идут через `worker` под per-download блокировкой —
|
Все переходы и команды идут через `worker` под per-download блокировкой —
|
||||||
два транспорта не гонятся за одно состояние. Состояние персистентно в
|
два транспорта не гонятся за одно состояние. Состояние персистентно в
|
||||||
SQLite; `worker` периодически сверяет qBittorrent с БД и **усыновляет**
|
SQLite; `worker` периодически сверяет qBittorrent с БД и **усыновляет**
|
||||||
|
|||||||
+15
-28
@@ -25,38 +25,25 @@
|
|||||||
матч в базе), [jellyfin-layout.md](specs/jellyfin-layout.md) (папка
|
матч в базе), [jellyfin-layout.md](specs/jellyfin-layout.md) (папка
|
||||||
сериала с провайдер-id).
|
сериала с провайдер-id).
|
||||||
|
|
||||||
### Рассинхрон состояния с реальностью (удалённый торрент / файлы)
|
### Удаление средствами jellybit («единое окно», path 2)
|
||||||
|
|
||||||
Состояние jellybit может разойтись с тем, что реально лежит на диске.
|
Распознавание **ручного** удаления (источник из qBittorrent / цель из
|
||||||
Несколько сценариев разной остроты:
|
Jellyfin) и пометка рассинхрона уже сделаны: фоновая сверка по матрице
|
||||||
|
«источник × цель» → состояния `target_missing`/`orphaned`/`deleted`,
|
||||||
|
безопасный `undo` (не снимает последнюю копию, `nlink <= 1`), синхронный
|
||||||
|
preflight перед действиями. См. `openspec/specs/state-reconciliation/`,
|
||||||
|
[workflow.md](specs/workflow.md) → «Сверка с реальностью».
|
||||||
|
|
||||||
- **Жёсткий — удалён источник.** Раздачу удаляют (вручную или авто по
|
Осталось (path 2) — продолжение «единого окна»: удалять просмотренное
|
||||||
достижении seed limit), и qBittorrent стирает скачанные файлы. Тогда
|
**из самого jellybit**, не идя руками в qBittorrent/Jellyfin. Нужно
|
||||||
хардлинк в библиотеке становится **последней** ссылкой на inode, и
|
продумать: команду удаления (снять наши хардлинки + опц. удалить раздачу из
|
||||||
обычный `undo` (`unlink` цели + чистка пустых каталогов) сотрёт
|
qBittorrent с файлами), подтверждение осознанности (а не случайный клик) и
|
||||||
единственную копию насовсем — прямая потеря данных. Инвариант «источник
|
как это сочетается с инвариантом «источник неприкосновенен», когда
|
||||||
неприкосновенен» молчаливо перестаёт держаться: источника уже нет.
|
пользователь сам просит убрать источник.
|
||||||
- **Мягкий — удалена цель.** Файлы убрали из библиотеки Jellyfin (вручную
|
|
||||||
или из самого Jellyfin), а jellybit по-прежнему числит загрузку в
|
|
||||||
`done`. Состояние врёт: ссылок уже нет, а сервис думает, что всё
|
|
||||||
разложено.
|
|
||||||
|
|
||||||
Нужно продумать сверку записанного состояния (`file_link`, состояние
|
|
||||||
загрузки) с фактом на ФС:
|
|
||||||
|
|
||||||
- как `worker` реагирует на исчезновение раздачи из qBittorrent
|
|
||||||
(состояние/пометка загрузки);
|
|
||||||
- как `undo` защищается, когда источник недоступен — например,
|
|
||||||
отказываться удалять, если у целевого файла счётчик ссылок == 1 (нет
|
|
||||||
второй копии) или исходный путь не существует, и явно об этом сообщать.
|
|
||||||
Откат снимает **лишний** хардлинк, а не последнюю копию файла;
|
|
||||||
- как ловить пропажу целевых файлов и отражать её в состоянии (напр.
|
|
||||||
периодическая сверка или проверка при показе — «разложено, но файлов
|
|
||||||
нет»), чтобы можно было осознанно перепривязать/переразложить.
|
|
||||||
|
|
||||||
Связано: [ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md),
|
Связано: [ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md),
|
||||||
[architecture.md](specs/architecture.md) → «Раскладка файлов» (undo,
|
[architecture.md](specs/architecture.md) → «Раскладка файлов»,
|
||||||
инвариант источника), [workflow.md](specs/workflow.md) (`done → reverted`).
|
[workflow.md](specs/workflow.md).
|
||||||
|
|
||||||
### Наблюдаемость: метрики и учёт стоимости LLM
|
### Наблюдаемость: метрики и учёт стоимости LLM
|
||||||
|
|
||||||
|
|||||||
@@ -76,12 +76,14 @@ type Metadata struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MetadataProvider — настройки одного провайдера метаданных. У keyless-баз
|
// MetadataProvider — настройки одного провайдера метаданных. У keyless-баз
|
||||||
// (TVMaze) поле api_key не используется.
|
// (TVMaze) поле api_key не используется; language учитывает только TMDB
|
||||||
|
// (локаль возвращаемых названий, дефолт ru-RU).
|
||||||
type MetadataProvider struct {
|
type MetadataProvider struct {
|
||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
APIKey string `toml:"api_key"`
|
APIKey string `toml:"api_key"`
|
||||||
Proxy string `toml:"proxy"`
|
Proxy string `toml:"proxy"`
|
||||||
Timeout Duration `toml:"timeout"`
|
Timeout Duration `toml:"timeout"`
|
||||||
|
Language string `toml:"language"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается
|
// Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается
|
||||||
@@ -99,6 +101,10 @@ type Worker struct {
|
|||||||
PollInterval Duration `toml:"poll_interval"`
|
PollInterval Duration `toml:"poll_interval"`
|
||||||
StuckAfter Duration `toml:"stuck_after"`
|
StuckAfter Duration `toml:"stuck_after"`
|
||||||
MagnetTimeout Duration `toml:"magnet_timeout"`
|
MagnetTimeout Duration `toml:"magnet_timeout"`
|
||||||
|
// SourceMissingThreshold — сколько подряд тиков сверки без раздачи в
|
||||||
|
// qBittorrent нужно, чтобы счесть источник удалённым (дебаунс пропажи,
|
||||||
|
// см. state-reconciliation). Любое появление раздачи сбрасывает счётчик.
|
||||||
|
SourceMissingThreshold int `toml:"source_missing_threshold"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recognition — пороги распознавания.
|
// Recognition — пороги распознавания.
|
||||||
@@ -168,7 +174,7 @@ func Default() *Config {
|
|||||||
MaxRetries: 3,
|
MaxRetries: 3,
|
||||||
},
|
},
|
||||||
Metadata: Metadata{
|
Metadata: Metadata{
|
||||||
TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
|
TMDB: MetadataProvider{Timeout: Duration(10 * time.Second), Language: "ru-RU"},
|
||||||
TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
|
TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
|
||||||
},
|
},
|
||||||
Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)},
|
Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)},
|
||||||
@@ -176,6 +182,7 @@ func Default() *Config {
|
|||||||
PollInterval: Duration(5 * time.Second),
|
PollInterval: Duration(5 * time.Second),
|
||||||
StuckAfter: Duration(time.Hour),
|
StuckAfter: Duration(time.Hour),
|
||||||
MagnetTimeout: Duration(30 * time.Minute),
|
MagnetTimeout: Duration(30 * time.Minute),
|
||||||
|
SourceMissingThreshold: 3,
|
||||||
},
|
},
|
||||||
Recognition: Recognition{AutoConfidenceThreshold: 0.85},
|
Recognition: Recognition{AutoConfidenceThreshold: 0.85},
|
||||||
HTTP: HTTP{Listen: ":8080"},
|
HTTP: HTTP{Listen: ":8080"},
|
||||||
@@ -241,12 +248,13 @@ func (c *Config) validate() error {
|
|||||||
if c.LLM.MaxRetries < 0 {
|
if c.LLM.MaxRetries < 0 {
|
||||||
errs = append(errs, fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries))
|
errs = append(errs, fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries))
|
||||||
}
|
}
|
||||||
|
if c.Worker.SourceMissingThreshold < 1 {
|
||||||
// Обязательные секреты включённых секций (ловит криво отрендеренный деплоем
|
errs = append(errs, fmt.Errorf("worker.source_missing_threshold %d must be >= 1", c.Worker.SourceMissingThreshold))
|
||||||
// файл). qBittorrent — ядро, пароль нужен всегда.
|
|
||||||
if c.QBittorrent.Password == "" {
|
|
||||||
errs = append(errs, errors.New("qbittorrent.password is empty (required secret)"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// qbittorrent.password намеренно не обязателен: qBittorrent может работать без
|
||||||
|
// аутентификации (например, обход авторизации для клиентов из доверенной
|
||||||
|
// подсети) — пустой пароль валиден.
|
||||||
// llm.api_key намеренно не обязателен: keyless-local LLM (LM Studio с
|
// llm.api_key намеренно не обязателен: keyless-local LLM (LM Studio с
|
||||||
// заданным base_url, но без ключа) — валидный документированный дефолт.
|
// заданным base_url, но без ключа) — валидный документированный дефолт.
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ func TestValidate_OK(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestValidate_NoQBittorrentPassword — пустой пароль qBittorrent валиден:
|
||||||
|
// клиент может работать без аутентификации (обход авторизации для доверенной
|
||||||
|
// подсети).
|
||||||
|
func TestValidate_NoQBittorrentPassword(t *testing.T) {
|
||||||
|
c := validCfg(t)
|
||||||
|
c.QBittorrent.Password = ""
|
||||||
|
if err := c.validate(); err != nil {
|
||||||
|
t.Fatalf("пустой пароль qBittorrent должен быть валиден, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestValidate_KeylessLocalLLM — keyless-local LLM (задан base_url, пустой
|
// TestValidate_KeylessLocalLLM — keyless-local LLM (задан base_url, пустой
|
||||||
// api_key, напр. LM Studio) — валиден: ключ не обязателен.
|
// api_key, напр. LM Studio) — валиден: ключ не обязателен.
|
||||||
func TestValidate_KeylessLocalLLM(t *testing.T) {
|
func TestValidate_KeylessLocalLLM(t *testing.T) {
|
||||||
@@ -51,7 +62,6 @@ func TestValidate_Errors(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"empty qbittorrent.url", func(c *Config) { c.QBittorrent.URL = "" }, "qbittorrent.url"},
|
{"empty qbittorrent.url", func(c *Config) { c.QBittorrent.URL = "" }, "qbittorrent.url"},
|
||||||
{"empty qbittorrent.password", func(c *Config) { c.QBittorrent.Password = "" }, "qbittorrent.password"},
|
|
||||||
{"empty db_path", func(c *Config) { c.Storage.DBPath = "" }, "storage.db_path"},
|
{"empty db_path", func(c *Config) { c.Storage.DBPath = "" }, "storage.db_path"},
|
||||||
{"bad llm.type", func(c *Config) { c.LLM.Type = "anthropic" }, "llm.type"},
|
{"bad llm.type", func(c *Config) { c.LLM.Type = "anthropic" }, "llm.type"},
|
||||||
{"relative movies", func(c *Config) { c.Paths.Movies = "movies" }, "absolute"},
|
{"relative movies", func(c *Config) { c.Paths.Movies = "movies" }, "absolute"},
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ type downloadView struct {
|
|||||||
Terminal bool
|
Terminal bool
|
||||||
Reviewable bool // review/deferred — есть экран ревью
|
Reviewable bool // review/deferred — есть экран ревью
|
||||||
Undoable bool // done — можно откатить раскладку
|
Undoable bool // done — можно откатить раскладку
|
||||||
Relinkable bool // reverted/cancelled — можно перепривязать заново
|
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
||||||
|
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -317,7 +318,23 @@ func toView(d store.Download) downloadView {
|
|||||||
Terminal: d.State.IsTerminal(),
|
Terminal: d.State.IsTerminal(),
|
||||||
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
||||||
Undoable: d.State == store.StateDone,
|
Undoable: d.State == store.StateDone,
|
||||||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled,
|
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
||||||
|
d.State == store.StateTargetMissing,
|
||||||
|
Note: desyncNote(d.State),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// desyncNote — пояснение состояния рассинхрона для UI (см. state-reconciliation).
|
||||||
|
func desyncNote(s store.State) string {
|
||||||
|
switch s {
|
||||||
|
case store.StateTargetMissing:
|
||||||
|
return "разложено, но файлов в библиотеке нет — можно привязать заново"
|
||||||
|
case store.StateOrphaned:
|
||||||
|
return "источник удалён — это последняя копия данных, откат недоступен"
|
||||||
|
case store.StateDeleted:
|
||||||
|
return "удалены и источник, и файлы в библиотеке"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,11 @@ type Result struct {
|
|||||||
// ErrCollision — цель существует и это другой файл (нужен review).
|
// ErrCollision — цель существует и это другой файл (нужен review).
|
||||||
var ErrCollision = errors.New("layout: target collision")
|
var ErrCollision = errors.New("layout: target collision")
|
||||||
|
|
||||||
|
// ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных
|
||||||
|
// (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не
|
||||||
|
// единственный файл (см. state-reconciliation, инвариант безопасного Undo).
|
||||||
|
var ErrLastCopy = errors.New("layout: refusing to remove last remaining copy")
|
||||||
|
|
||||||
// Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя
|
// Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя
|
||||||
// доводит начатое. При коллизии (цель занята чужим файлом) возвращает
|
// доводит начатое. При коллизии (цель занята чужим файлом) возвращает
|
||||||
// ErrCollision, не перезаписывая. Если хардлинк невозможен (разные ФС или ФС
|
// ErrCollision, не перезаписывая. Если хардлинк невозможен (разные ФС или ФС
|
||||||
@@ -367,16 +372,41 @@ func sameFile(src, dst string) (bool, error) {
|
|||||||
// Undo удаляет ссылки и подчищает опустевшие каталоги. Снимает только пути
|
// Undo удаляет ссылки и подчищает опустевшие каталоги. Снимает только пути
|
||||||
// строго под библиотеками (источник недосягаем). Отсутствующая цель — не
|
// строго под библиотеками (источник недосягаем). Отсутствующая цель — не
|
||||||
// ошибка (идемпотентно). Возвращает число удалённых ссылок.
|
// ошибка (идемпотентно). Возвращает число удалённых ссылок.
|
||||||
|
//
|
||||||
|
// Защита от потери данных: сперва предпроверка всего батча — если хоть одна
|
||||||
|
// существующая цель оказывается последней копией (источник пропал или
|
||||||
|
// nlink<=1), весь Undo отклоняется с ErrLastCopy и НИ ОДНА ссылка не
|
||||||
|
// снимается (иначе частичный откат стёр бы часть данных). Так откат снимает
|
||||||
|
// лишний хардлинк, а не единственный файл.
|
||||||
func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) {
|
func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) {
|
||||||
log := logctx.FromOr(ctx, l.log)
|
log := logctx.FromOr(ctx, l.log)
|
||||||
|
|
||||||
|
// Предпроверка: путь под библиотекой + не последняя копия.
|
||||||
|
for _, ln := range links {
|
||||||
|
if _, err := undoRoot(l, ln.Dst); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
fi, err := os.Lstat(ln.Dst)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue // цели уже нет — снимать нечего (идемпотентно)
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("layout: undo stat %q: %w", ln.Dst, err)
|
||||||
|
}
|
||||||
|
last, err := isLastCopy(fi, ln.Src)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("layout: undo check %q: %w", ln.Dst, err)
|
||||||
|
}
|
||||||
|
if last {
|
||||||
|
return 0, fmt.Errorf("%w: %q (источник %q недоступен)", ErrLastCopy, ln.Dst, ln.Src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
removed := 0
|
removed := 0
|
||||||
for _, ln := range links {
|
for _, ln := range links {
|
||||||
root := l.movies
|
root, err := undoRoot(l, ln.Dst)
|
||||||
if !underRoot(l.movies, ln.Dst) {
|
if err != nil {
|
||||||
root = l.series
|
return removed, err
|
||||||
}
|
|
||||||
if !underRoot(root, ln.Dst) {
|
|
||||||
return removed, fmt.Errorf("layout: undo outside library: %q", ln.Dst)
|
|
||||||
}
|
}
|
||||||
if err := os.Remove(ln.Dst); err != nil {
|
if err := os.Remove(ln.Dst); err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
@@ -392,6 +422,36 @@ func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) {
|
|||||||
return removed, nil
|
return removed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// undoRoot возвращает корень библиотеки, под которым лежит dst, либо ошибку,
|
||||||
|
// если путь не под movies/series (откат трогает только библиотеку).
|
||||||
|
func undoRoot(l *Layouter, dst string) (string, error) {
|
||||||
|
root := l.movies
|
||||||
|
if !underRoot(l.movies, dst) {
|
||||||
|
root = l.series
|
||||||
|
}
|
||||||
|
if !underRoot(root, dst) {
|
||||||
|
return "", fmt.Errorf("layout: undo outside library: %q", dst)
|
||||||
|
}
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLastCopy сообщает, является ли цель последней копией данных: исходный файл
|
||||||
|
// уже не существует, либо у цели не осталось других жёстких ссылок (nlink<=1).
|
||||||
|
// В обоих случаях unlink цели уничтожил бы единственную копию.
|
||||||
|
func isLastCopy(fi os.FileInfo, src string) (bool, error) {
|
||||||
|
if _, err := os.Lstat(src); err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return true, nil // источника нет — цель единственная копия
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("stat source %q: %w", src, err)
|
||||||
|
}
|
||||||
|
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("unexpected stat type for %q", fi.Name())
|
||||||
|
}
|
||||||
|
return st.Nlink <= 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
// pruneEmptyDirs удаляет опустевшие каталоги вверх до (не включая) root.
|
// pruneEmptyDirs удаляет опустевшие каталоги вверх до (не включая) root.
|
||||||
// Ошибки игнорируются: непустой каталог os.Remove не удалит — это и нужно.
|
// Ошибки игнорируются: непустой каталог os.Remove не удалит — это и нужно.
|
||||||
func pruneEmptyDirs(dir, root string) {
|
func pruneEmptyDirs(dir, root string) {
|
||||||
|
|||||||
@@ -232,6 +232,60 @@ func TestUndo_RemovesLinksAndPrunesDirs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUndo_RefusesLastCopyWhenSourceGone(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
|
||||||
|
Files: []PlanFile{{Src: f.srcFile(t, "m/film.mkv", "data"), Role: RoleMain}}})
|
||||||
|
if _, err := f.l.Apply(context.Background(), links); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Источник удалён (как при удалении раздачи из qBittorrent) → цель стала
|
||||||
|
// последней копией (nlink упал до 1).
|
||||||
|
if err := os.Remove(links[0].Src); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := f.l.Undo(context.Background(), links)
|
||||||
|
if !errors.Is(err, ErrLastCopy) {
|
||||||
|
t.Fatalf("err = %v, want ErrLastCopy", err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("removed = %d, want 0 (ничего не сняли)", n)
|
||||||
|
}
|
||||||
|
// Цель НЕ удалена — данные целы.
|
||||||
|
if _, err := os.Stat(links[0].Dst); err != nil {
|
||||||
|
t.Errorf("target must remain (last copy): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUndo_RefusesWholeBatchIfAnyLastCopy(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
links, _ := f.l.BuildLinks(Plan{Type: Series, Title: "Show", Year: 2021,
|
||||||
|
Files: []PlanFile{
|
||||||
|
{Src: f.srcFile(t, "s/e1.mkv", "1"), Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
|
||||||
|
{Src: f.srcFile(t, "s/e2.mkv", "2"), Role: RoleEpisode, Season: intp(1), Episode: intp(2)},
|
||||||
|
}})
|
||||||
|
if _, err := f.l.Apply(context.Background(), links); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Источник второй серии пропал — весь батч должен быть отклонён целиком.
|
||||||
|
if err := os.Remove(links[1].Src); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := f.l.Undo(context.Background(), links)
|
||||||
|
if !errors.Is(err, ErrLastCopy) {
|
||||||
|
t.Fatalf("err = %v, want ErrLastCopy", err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("removed = %d, want 0 (батч не трогаем)", n)
|
||||||
|
}
|
||||||
|
// Первая ссылка (источник жив) НЕ снята — частичного отката нет.
|
||||||
|
if _, err := os.Stat(links[0].Dst); err != nil {
|
||||||
|
t.Errorf("first target must remain (no partial undo): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUndo_Idempotent(t *testing.T) {
|
func TestUndo_Idempotent(t *testing.T) {
|
||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
|
links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
const tmdbDefaultBaseURL = "https://api.themoviedb.org/3"
|
const (
|
||||||
|
tmdbDefaultBaseURL = "https://api.themoviedb.org/3"
|
||||||
|
tmdbDefaultLanguage = "ru-RU"
|
||||||
|
)
|
||||||
|
|
||||||
// TMDBConfig — настройки клиента TMDB.
|
// TMDBConfig — настройки клиента TMDB.
|
||||||
type TMDBConfig struct {
|
type TMDBConfig struct {
|
||||||
@@ -21,12 +24,14 @@ type TMDBConfig struct {
|
|||||||
Proxy string
|
Proxy string
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
BaseURL string // пусто → api.themoviedb.org; задаётся в тестах
|
BaseURL string // пусто → api.themoviedb.org; задаётся в тестах
|
||||||
|
Language string // локаль возвращаемых названий; пусто → ru-RU
|
||||||
}
|
}
|
||||||
|
|
||||||
// TMDB — клиент The Movie Database (API v3, авторизация по api_key).
|
// TMDB — клиент The Movie Database (API v3, авторизация по api_key).
|
||||||
type TMDB struct {
|
type TMDB struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
baseURL string
|
baseURL string
|
||||||
|
language string
|
||||||
hc *http.Client
|
hc *http.Client
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -44,10 +49,14 @@ func NewTMDB(cfg TMDBConfig, logger *slog.Logger) (*TMDB, error) {
|
|||||||
if base == "" {
|
if base == "" {
|
||||||
base = tmdbDefaultBaseURL
|
base = tmdbDefaultBaseURL
|
||||||
}
|
}
|
||||||
|
lang := cfg.Language
|
||||||
|
if lang == "" {
|
||||||
|
lang = tmdbDefaultLanguage
|
||||||
|
}
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
logger = slog.Default()
|
||||||
}
|
}
|
||||||
return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc, log: logger}, nil
|
return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), language: lang, hc: hc, log: logger}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TMDB) Name() string { return "tmdb" }
|
func (t *TMDB) Name() string { return "tmdb" }
|
||||||
@@ -67,7 +76,7 @@ type tmdbSearchResp struct {
|
|||||||
// Search ищет фильм/сериал по названию и году.
|
// Search ищет фильм/сериал по названию и году.
|
||||||
func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
||||||
var path string
|
var path string
|
||||||
params := url.Values{"api_key": {t.apiKey}, "query": {q.Title}, "include_adult": {"false"}}
|
params := url.Values{"api_key": {t.apiKey}, "query": {q.Title}, "include_adult": {"false"}, "language": {t.language}}
|
||||||
switch q.Type {
|
switch q.Type {
|
||||||
case Movie:
|
case Movie:
|
||||||
path = "/search/movie"
|
path = "/search/movie"
|
||||||
|
|||||||
@@ -102,6 +102,45 @@ func TestTMDB_ErrorStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTMDB_SearchDefaultLanguage(t *testing.T) {
|
||||||
|
// Язык не задан в конфиге → дефолт ru-RU; original_title не зависит от локали.
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.URL.Query().Get("language"); got != "ru-RU" {
|
||||||
|
t.Errorf("language = %q, want ru-RU", got)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"results":[
|
||||||
|
{"id":155,"title":"Тёмный рыцарь","original_title":"The Dark Knight","release_date":"2008-07-18"}
|
||||||
|
]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
got, err := newTMDB(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "The Dark Knight", Year: 2008})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
if got[0].Title != "Тёмный рыцарь" || got[0].OriginalTitle != "The Dark Knight" {
|
||||||
|
t.Errorf("candidate = %+v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTMDB_SearchLanguageOverride(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.URL.Query().Get("language"); got != "en-US" {
|
||||||
|
t.Errorf("language = %q, want en-US", got)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c, err := NewTMDB(TMDBConfig{APIKey: "k", BaseURL: srv.URL, Language: "en-US"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTMDB: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := c.Search(context.Background(), Query{Type: Movie, Title: "X"}); err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewTMDB_RequiresKey(t *testing.T) {
|
func TestNewTMDB_RequiresKey(t *testing.T) {
|
||||||
if _, err := NewTMDB(TMDBConfig{}, nil); err == nil {
|
if _, err := NewTMDB(TMDBConfig{}, nil); err == nil {
|
||||||
t.Fatal("want error without api_key")
|
t.Fatal("want error without api_key")
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ const maxCandidates = 8
|
|||||||
// matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
|
// matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
|
||||||
// сильный матч — ровно один кандидат с совпадением названия и года (для него
|
// сильный матч — ровно один кандидат с совпадением названия и года (для него
|
||||||
// тянем число серий и используем для авто), либо nil; (б) список кандидатов
|
// тянем число серий и используем для авто), либо nil; (б) список кандидатов
|
||||||
// из всех провайдеров (топ-N, дедуп) — чтобы человек мог выбрать в review,
|
// из всех выполненных заходов (топ-N, дедуп) — чтобы человек мог выбрать в
|
||||||
// когда сильного матча нет. Ошибки провайдера не валят распознавание.
|
// review, когда сильного матча нет. Ошибки провайдера не валят распознавание.
|
||||||
|
//
|
||||||
|
// Поиск идёт по нескольким названиям в порядке убывания силы ключа
|
||||||
|
// (original_title → title → provider_hint, см. searchKeys): базы индексированы
|
||||||
|
// прежде всего по оригинальным названиям. Останавливаемся, как только очередной
|
||||||
|
// ключ дал единичный сильный матч (ранний стоп — дешевле по обращениям к базе).
|
||||||
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
|
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
|
||||||
if len(r.providers) == 0 {
|
if len(r.providers) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -26,18 +31,15 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
|
|||||||
mt = metadata.Series
|
mt = metadata.Series
|
||||||
}
|
}
|
||||||
|
|
||||||
searchTitle := plan.ProviderHint
|
|
||||||
if strings.TrimSpace(searchTitle) == "" {
|
|
||||||
searchTitle = plan.Title
|
|
||||||
}
|
|
||||||
matchTitles := normSet(plan.Title, plan.OriginalTitle)
|
matchTitles := normSet(plan.Title, plan.OriginalTitle)
|
||||||
|
|
||||||
var match *Match
|
var match *Match
|
||||||
var candidates []metadata.Candidate
|
var candidates []metadata.Candidate
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|
||||||
|
for _, key := range searchKeys(plan) {
|
||||||
for _, p := range r.providers {
|
for _, p := range r.providers {
|
||||||
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year})
|
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: plan.Year})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
|
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
|
||||||
// доменное решение «пропускаем провайдера, пробуем следующий».
|
// доменное решение «пропускаем провайдера, пробуем следующий».
|
||||||
@@ -47,11 +49,11 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
|
|||||||
|
|
||||||
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
|
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
|
||||||
for _, c := range cands {
|
for _, c := range cands {
|
||||||
key := c.Provider + ":" + c.ID
|
ck := c.Provider + ":" + c.ID
|
||||||
if seen[key] || len(candidates) >= maxCandidates {
|
if seen[ck] || len(candidates) >= maxCandidates {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[key] = true
|
seen[ck] = true
|
||||||
candidates = append(candidates, c)
|
candidates = append(candidates, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,9 +67,35 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
|
|||||||
}
|
}
|
||||||
match = r.buildMatch(ctx, p, strong[0], mt)
|
match = r.buildMatch(ctx, p, strong[0], mt)
|
||||||
}
|
}
|
||||||
|
if match != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
return match, candidates
|
return match, candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// searchKeys строит ключи поиска по базе в порядке убывания силы:
|
||||||
|
// original_title → title → provider_hint. Пустые и нормализованно совпадающие
|
||||||
|
// с уже добавленным ключом пропускаем, чтобы не обращаться к базе дважды с тем
|
||||||
|
// же запросом (частый случай — российский фильм, где original_title дублирует
|
||||||
|
// title).
|
||||||
|
func searchKeys(plan Plan) []string {
|
||||||
|
var keys []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, t := range []string{plan.OriginalTitle, plan.Title, plan.ProviderHint} {
|
||||||
|
if strings.TrimSpace(t) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n := normalize(t)
|
||||||
|
if n == "" || seen[n] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[n] = true
|
||||||
|
keys = append(keys, t)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
// buildMatch тянет число серий (по нативному id) и собирает Match с
|
// buildMatch тянет число серий (по нативному id) и собирает Match с
|
||||||
// тег-предпочтительным провенансом.
|
// тег-предпочтительным провенансом.
|
||||||
func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c metadata.Candidate, mt metadata.MediaType) *Match {
|
func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c metadata.Candidate, mt metadata.MediaType) *Match {
|
||||||
@@ -146,11 +174,15 @@ func normSet(titles ...string) map[string]bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// normalize приводит название к сравнимому виду: нижний регистр, только
|
// normalize приводит название к сравнимому виду: нижний регистр, только
|
||||||
// буквы/цифры (юникод), одиночные пробелы.
|
// буквы/цифры (юникод), одиночные пробелы. Букву ё сводим к е (частое
|
||||||
|
// расхождение написания: «Тёмный» vs «Темный»).
|
||||||
func normalize(s string) string {
|
func normalize(s string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
prevSpace := false
|
prevSpace := false
|
||||||
for _, r := range strings.ToLower(s) {
|
for _, r := range strings.ToLower(s) {
|
||||||
|
if r == 'ё' {
|
||||||
|
r = 'е'
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package recognize
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||||
@@ -11,9 +12,11 @@ import (
|
|||||||
type fakeProvider struct {
|
type fakeProvider struct {
|
||||||
name string
|
name string
|
||||||
candidates []metadata.Candidate
|
candidates []metadata.Candidate
|
||||||
|
byTitle map[string][]metadata.Candidate // если задано — результат зависит от запроса
|
||||||
counts map[int]int
|
counts map[int]int
|
||||||
searchErr error
|
searchErr error
|
||||||
searched int
|
searched int
|
||||||
|
queries []string // строки запросов в порядке вызова
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeProvider) Name() string {
|
func (f *fakeProvider) Name() string {
|
||||||
@@ -22,9 +25,16 @@ func (f *fakeProvider) Name() string {
|
|||||||
}
|
}
|
||||||
return f.name
|
return f.name
|
||||||
}
|
}
|
||||||
func (f *fakeProvider) Search(_ context.Context, _ metadata.Query) ([]metadata.Candidate, error) {
|
func (f *fakeProvider) Search(_ context.Context, q metadata.Query) ([]metadata.Candidate, error) {
|
||||||
f.searched++
|
f.searched++
|
||||||
return f.candidates, f.searchErr
|
f.queries = append(f.queries, q.Title)
|
||||||
|
if f.searchErr != nil {
|
||||||
|
return nil, f.searchErr
|
||||||
|
}
|
||||||
|
if f.byTitle != nil {
|
||||||
|
return f.byTitle[q.Title], nil
|
||||||
|
}
|
||||||
|
return f.candidates, nil
|
||||||
}
|
}
|
||||||
func (f *fakeProvider) SeasonEpisodeCounts(_ context.Context, _ string) (map[int]int, error) {
|
func (f *fakeProvider) SeasonEpisodeCounts(_ context.Context, _ string) (map[int]int, error) {
|
||||||
return f.counts, nil
|
return f.counts, nil
|
||||||
@@ -132,6 +142,75 @@ func TestMatchMetadata_OriginalTitle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMatchMetadata_MatchByOriginalFirst(t *testing.T) {
|
||||||
|
// Реальный кейс: русское релиз-имя, матч по оригинальному названию.
|
||||||
|
// При сильном матче по первому ключу дальнейшие запросы не делаются.
|
||||||
|
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
|
||||||
|
"The Dark Knight": {{Provider: "tmdb", ID: "155", Title: "The Dark Knight", Year: 2008}},
|
||||||
|
}}
|
||||||
|
r := recognizerWith(p)
|
||||||
|
m, _ := r.matchMetadata(context.Background(),
|
||||||
|
Plan{Type: MediaMovie, Title: "Тёмный рыцарь", OriginalTitle: "The Dark Knight", Year: 2008})
|
||||||
|
if m == nil || m.ProviderID != "155" {
|
||||||
|
t.Fatalf("should match by original title, got %+v", m)
|
||||||
|
}
|
||||||
|
if want := []string{"The Dark Knight"}; !slices.Equal(p.queries, want) {
|
||||||
|
t.Errorf("queries = %v, want %v (ранний стоп на оригинале)", p.queries, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchMetadata_FallbackToTitle(t *testing.T) {
|
||||||
|
// Запрос по original ничего не дал — фолбэк на локализованный title.
|
||||||
|
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
|
||||||
|
"Wrong Original": {},
|
||||||
|
"Fargo": {{Provider: "tmdb", ID: "1", Title: "Fargo", Year: 2014}},
|
||||||
|
}}
|
||||||
|
r := recognizerWith(p)
|
||||||
|
m, _ := r.matchMetadata(context.Background(),
|
||||||
|
Plan{Type: MediaSeries, Title: "Fargo", OriginalTitle: "Wrong Original", Year: 2014})
|
||||||
|
if m == nil || m.ProviderID != "1" {
|
||||||
|
t.Fatalf("should fall back to title, got %+v", m)
|
||||||
|
}
|
||||||
|
if want := []string{"Wrong Original", "Fargo"}; !slices.Equal(p.queries, want) {
|
||||||
|
t.Errorf("queries = %v, want %v", p.queries, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchMetadata_FallbackToProviderHint(t *testing.T) {
|
||||||
|
// Ни original, ни title не нашли — третий фолбэк по provider_hint.
|
||||||
|
// Гейт по-прежнему требует совпадения названия кандидата с title/original.
|
||||||
|
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
|
||||||
|
"Orig": {},
|
||||||
|
"Loc": {},
|
||||||
|
"Hint": {{Provider: "tmdb", ID: "7", Title: "Loc", Year: 2000}},
|
||||||
|
}}
|
||||||
|
r := recognizerWith(p)
|
||||||
|
m, _ := r.matchMetadata(context.Background(),
|
||||||
|
Plan{Type: MediaMovie, Title: "Loc", OriginalTitle: "Orig", ProviderHint: "Hint", Year: 2000})
|
||||||
|
if m == nil || m.ProviderID != "7" {
|
||||||
|
t.Fatalf("should fall back to provider_hint, got %+v", m)
|
||||||
|
}
|
||||||
|
if want := []string{"Orig", "Loc", "Hint"}; !slices.Equal(p.queries, want) {
|
||||||
|
t.Errorf("queries = %v, want %v", p.queries, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchMetadata_SkipsDuplicateKey(t *testing.T) {
|
||||||
|
// Российский фильм: original_title дублирует title — база дёргается раз.
|
||||||
|
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
|
||||||
|
"Брат": {{Provider: "tmdb", ID: "1", Title: "Брат", Year: 1997}},
|
||||||
|
}}
|
||||||
|
r := recognizerWith(p)
|
||||||
|
m, _ := r.matchMetadata(context.Background(),
|
||||||
|
Plan{Type: MediaMovie, Title: "Брат", OriginalTitle: "Брат", Year: 1997})
|
||||||
|
if m == nil || m.ProviderID != "1" {
|
||||||
|
t.Fatalf("expected match, got %+v", m)
|
||||||
|
}
|
||||||
|
if p.searched != 1 {
|
||||||
|
t.Errorf("searched = %d, want 1 (дубль-ключ пропущен)", p.searched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMatchMetadata_TagFromExternal(t *testing.T) {
|
func TestMatchMetadata_TagFromExternal(t *testing.T) {
|
||||||
// TVMaze-стиль: нативный id для счёта серий, внешний TVDB-id для тега.
|
// TVMaze-стиль: нативный id для счёта серий, внешний TVDB-id для тега.
|
||||||
p := &fakeProvider{
|
p := &fakeProvider{
|
||||||
@@ -191,6 +270,8 @@ func TestNormalize(t *testing.T) {
|
|||||||
"Léon: The Pro!": "léon the pro",
|
"Léon: The Pro!": "léon the pro",
|
||||||
" A B ": "a b",
|
" A B ": "a b",
|
||||||
"Привет, Мир": "привет мир",
|
"Привет, Мир": "привет мир",
|
||||||
|
"Тёмный рыцарь": "темный рыцарь", // ё → е
|
||||||
|
"ЁЖ": "еж",
|
||||||
}
|
}
|
||||||
for in, want := range cases {
|
for in, want := range cases {
|
||||||
if got := normalize(in); got != want {
|
if got := normalize(in); got != want {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
|
|||||||
{
|
{
|
||||||
"type": "movie" | "series",
|
"type": "movie" | "series",
|
||||||
"title": "каноническое название",
|
"title": "каноническое название",
|
||||||
"original_title": "оригинальное название или пустая строка",
|
"original_title": "оригинальное название (заполняй всегда, см. правила)",
|
||||||
"year": число или 0,
|
"year": число или 0,
|
||||||
"provider_hint": "строка для поиска в базе (НЕ id)",
|
"provider_hint": "строка для поиска в базе (НЕ id)",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -47,6 +47,11 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
|
|||||||
}
|
}
|
||||||
|
|
||||||
Правила:
|
Правила:
|
||||||
|
- Всегда заполняй И "title", И "original_title". Базы метаданных ищут прежде
|
||||||
|
всего по оригинальному (обычно английскому) названию, поэтому "original_title"
|
||||||
|
важен. Если отдельного оригинального названия нет или контент российского
|
||||||
|
происхождения — продублируй "title" в "original_title". Если НЕ уверен в
|
||||||
|
оригинальном названии — продублируй "title", но НЕ выдумывай название.
|
||||||
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
|
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
|
||||||
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
|
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
|
||||||
- Для фильма ровно один основной видеофайл role "main".
|
- Для фильма ровно один основной видеофайл role "main".
|
||||||
|
|||||||
+44
-10
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -26,19 +27,34 @@ const (
|
|||||||
StateFailed State = "failed"
|
StateFailed State = "failed"
|
||||||
StateCancelled State = "cancelled"
|
StateCancelled State = "cancelled"
|
||||||
StateReverted State = "reverted" // Ф3
|
StateReverted State = "reverted" // Ф3
|
||||||
|
|
||||||
|
// Состояния рассинхрона с реальностью (см. state-reconciliation).
|
||||||
|
StateTargetMissing State = "target_missing" // источник есть, цель удалена → relink
|
||||||
|
StateOrphaned State = "orphaned" // источник пропал, цель (последняя копия) есть
|
||||||
|
StateDeleted State = "deleted" // нет ни источника, ни цели
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// terminalStates — единый список окончательно остановленных состояний:
|
||||||
|
// источник истины и для IsTerminal, и для выборки «активных» задач
|
||||||
|
// (FindActiveByInfohash). Любое новое терминальное состояние добавляется
|
||||||
|
// ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key
|
||||||
|
// снимается по IsTerminal, а активность считалась бы по другому списку).
|
||||||
|
//
|
||||||
|
// Состояния рассинхрона (target_missing/orphaned/deleted) — терминальны по
|
||||||
|
// тем же причинам, что reverted/cancelled: дальше двигает либо человек
|
||||||
|
// (relink из target_missing), либо фоновая сверка (healing/прогрессия),
|
||||||
|
// напрямую через SetDownloadState; ключ идемпотентности при этом не нужен.
|
||||||
|
var terminalStates = []State{
|
||||||
|
StateDone, StateCancelled, StateFailed, StateReverted,
|
||||||
|
StateTargetMissing, StateOrphaned, StateDeleted,
|
||||||
|
}
|
||||||
|
|
||||||
// IsTerminal сообщает, завершена ли задача окончательно. Для терминальных
|
// IsTerminal сообщает, завершена ли задача окончательно. Для терминальных
|
||||||
// состояний снимается ключ идемпотентности — тот же infohash можно завести
|
// состояний снимается ключ идемпотентности — тот же infohash можно завести
|
||||||
// заново новой задачей (см. architecture.md, «повторное добавление»).
|
// заново новой задачей (см. architecture.md, «повторное добавление»).
|
||||||
// stuck терминальным не считается: задача восстановима (retry).
|
// stuck терминальным не считается: задача восстановима (retry).
|
||||||
func (s State) IsTerminal() bool {
|
func (s State) IsTerminal() bool {
|
||||||
switch s {
|
return slices.Contains(terminalStates, s)
|
||||||
case StateDone, StateCancelled, StateFailed, StateReverted:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceType — вид источника загрузки.
|
// SourceType — вид источника загрузки.
|
||||||
@@ -61,6 +77,9 @@ type Download struct {
|
|||||||
State State `db:"state"`
|
State State `db:"state"`
|
||||||
ErrorCode sql.NullString `db:"error_code"`
|
ErrorCode sql.NullString `db:"error_code"`
|
||||||
ErrorMsg sql.NullString `db:"error_msg"`
|
ErrorMsg sql.NullString `db:"error_msg"`
|
||||||
|
// SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в
|
||||||
|
// qBittorrent (дебаунс пропажи источника, см. state-reconciliation).
|
||||||
|
SourceMissCount int `db:"source_miss_count"`
|
||||||
CreatedAt string `db:"created_at"`
|
CreatedAt string `db:"created_at"`
|
||||||
UpdatedAt string `db:"updated_at"`
|
UpdatedAt string `db:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -141,11 +160,12 @@ func (s *Store) ListDownloadsByState(ctx context.Context, states ...State) ([]Do
|
|||||||
// FindActiveByInfohash возвращает незавершённую задачу для infohash либо
|
// FindActiveByInfohash возвращает незавершённую задачу для infohash либо
|
||||||
// (nil, nil), если её нет. Основа идемпотентного приёма.
|
// (nil, nil), если её нет. Основа идемпотентного приёма.
|
||||||
func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) {
|
func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) {
|
||||||
term := []State{StateDone, StateCancelled, StateFailed, StateReverted}
|
// «Активна» = не в терминальном состоянии. Список — единый с IsTerminal
|
||||||
ph := make([]string, len(term))
|
// (terminalStates), иначе семантика активности разъедется с idempotency_key.
|
||||||
args := make([]any, 0, len(term)+1)
|
ph := make([]string, len(terminalStates))
|
||||||
|
args := make([]any, 0, len(terminalStates)+1)
|
||||||
args = append(args, infohash)
|
args = append(args, infohash)
|
||||||
for i, st := range term {
|
for i, st := range terminalStates {
|
||||||
ph[i] = "?"
|
ph[i] = "?"
|
||||||
args = append(args, string(st))
|
args = append(args, string(st))
|
||||||
}
|
}
|
||||||
@@ -205,6 +225,20 @@ WHERE id = ?`
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSourceMissCount записывает счётчик пропусков источника (дебаунс сверки).
|
||||||
|
// Состояние не трогает — это отдельная от перехода фоновая отметка.
|
||||||
|
func (s *Store) SetSourceMissCount(ctx context.Context, id int64, 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)
|
||||||
|
}
|
||||||
|
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||||
|
return fmt.Errorf("set download %d source_miss_count: not found", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// nullArg возвращает nil для пустой строки (чтобы писать NULL, не "").
|
// nullArg возвращает nil для пустой строки (чтобы писать NULL, не "").
|
||||||
func nullArg(s string) any {
|
func nullArg(s string) any {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
|
|||||||
@@ -72,6 +72,31 @@ func TestFindActiveByInfohash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Состояния рассинхрона (target_missing/orphaned/deleted) терминальны: задача
|
||||||
|
// в них не должна считаться «активной» (иначе relink/ingest-дедуп решат, что
|
||||||
|
// для infohash уже есть активная задача). Регрессия: FindActiveByInfohash и
|
||||||
|
// IsTerminal обязаны опираться на один список терминальных состояний.
|
||||||
|
func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
|
||||||
|
for _, st := range []State{StateTargetMissing, StateOrphaned, StateDeleted} {
|
||||||
|
t.Run(string(st), func(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
ih := "33333333333333333333333333333333333333" + string(st[0:2])
|
||||||
|
|
||||||
|
id, err := store.CreateDownload(ctx, newDownloading(ih))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.SetDownloadState(ctx, id, st, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d, err := store.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
|
||||||
|
t.Fatalf("%s: активной задачи быть не должно, получили (%v,%v)", st, d, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Терминальное состояние снимает ключ идемпотентности и позволяет завести
|
// Терминальное состояние снимает ключ идемпотентности и позволяет завести
|
||||||
// тот же infohash заново (повторная закачка спустя время).
|
// тот же infohash заново (повторная закачка спустя время).
|
||||||
func TestTerminalReleasesInfohash(t *testing.T) {
|
func TestTerminalReleasesInfohash(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Счётчик подряд идущих тиков фоновой сверки без раздачи в qBittorrent.
|
||||||
|
-- Дебаунс пропажи источника: помечаем orphaned/deleted только при достижении
|
||||||
|
-- порога [worker].source_missing_threshold (см. state-reconciliation).
|
||||||
|
ALTER TABLE download ADD COLUMN source_miss_count INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE download DROP COLUMN source_miss_count;
|
||||||
@@ -246,6 +246,8 @@ func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyE
|
|||||||
switch event {
|
switch event {
|
||||||
case worker.EventDone:
|
case worker.EventDone:
|
||||||
text = b.renderDone(rd)
|
text = b.renderDone(rd)
|
||||||
|
case worker.EventTargetMissing, worker.EventOrphaned:
|
||||||
|
text, kb = b.renderDesync(rd, event), b.webOnly(downloadID)
|
||||||
default:
|
default:
|
||||||
text, kb = b.renderCard(rd)
|
text, kb = b.renderCard(rd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,22 @@ func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
|||||||
return fmt.Sprintf("✅ Готово: «%s» — разложено файлов: %d.", title, n)
|
return fmt.Sprintf("✅ Готово: «%s» — разложено файлов: %d.", title, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
|
||||||
|
func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string {
|
||||||
|
title := rd.Plan.Title
|
||||||
|
if title == "" {
|
||||||
|
title = "#" + itoa(rd.Download.ID)
|
||||||
|
}
|
||||||
|
switch event {
|
||||||
|
case worker.EventTargetMissing:
|
||||||
|
return fmt.Sprintf("⚠️ «%s»: файлы удалены из библиотеки, источник на месте — можно привязать заново.", title)
|
||||||
|
case worker.EventOrphaned:
|
||||||
|
return fmt.Sprintf("⚠️ «%s»: источник удалён из qBittorrent, библиотечная копия осталась последней (откат недоступен).", title)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("⚠️ «%s»: рассинхрон состояния.", title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
|
func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
|
||||||
url := b.reviewURL(id)
|
url := b.reviewURL(id)
|
||||||
if url == "" {
|
if url == "" {
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// desyncStates — состояния, которые ведёт сверка с реальностью: уже
|
||||||
|
// разложенные (done) и сами состояния рассинхрона. Активные и
|
||||||
|
// пользовательски-терминальные (reverted/cancelled/failed/stuck) сверка не
|
||||||
|
// трогает (см. state-reconciliation).
|
||||||
|
var desyncStates = []store.State{
|
||||||
|
store.StateDone,
|
||||||
|
store.StateTargetMissing,
|
||||||
|
store.StateOrphaned,
|
||||||
|
store.StateDeleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveState выводит состояние задачи из двумерной матрицы «источник × цель»
|
||||||
|
// (см. state-reconciliation, D1).
|
||||||
|
func deriveState(sourcePresent, targetPresent bool) store.State {
|
||||||
|
switch {
|
||||||
|
case sourcePresent && targetPresent:
|
||||||
|
return store.StateDone
|
||||||
|
case sourcePresent && !targetPresent:
|
||||||
|
return store.StateTargetMissing
|
||||||
|
case !sourcePresent && targetPresent:
|
||||||
|
return store.StateOrphaned
|
||||||
|
default:
|
||||||
|
return store.StateDeleted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileDesync сверяет разложенные/desync-задачи с реальностью. Вызывается
|
||||||
|
// из Poll под w.mu. byHash — карта присутствующих в qBittorrent раздач.
|
||||||
|
func (w *Worker) reconcileDesync(ctx context.Context, byHash map[string]qbt.Torrent) {
|
||||||
|
cands, err := w.store.ListDownloadsByState(ctx, desyncStates...)
|
||||||
|
if err != nil {
|
||||||
|
w.log.Warn("reconcile list desync failed", "capability", capIngest, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, d := range cands {
|
||||||
|
w.reconcileOneDesync(ctx, d, byHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileOneDesync сверяет одну задачу: вычисляет присутствие источника (с
|
||||||
|
// дебаунсом) и цели, выводит состояние и переходит при изменении.
|
||||||
|
func (w *Worker) reconcileOneDesync(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
|
||||||
|
if !d.Infohash.Valid {
|
||||||
|
return // нечем сопоставить источник
|
||||||
|
}
|
||||||
|
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
|
||||||
|
_, sourceSeen := byHash[strings.ToLower(d.Infohash.String)]
|
||||||
|
|
||||||
|
// Дебаунс пропажи источника: считаем удалённым только после порога подряд
|
||||||
|
// идущих промахов; любое появление сбрасывает счётчик.
|
||||||
|
sourcePresent := w.debounceSource(ctx, d, sourceSeen)
|
||||||
|
|
||||||
|
targetPresent, err := w.targetPresent(ctx, d.ID)
|
||||||
|
if err != nil {
|
||||||
|
logctx.From(ctx).Warn("reconcile target probe failed", "error", err)
|
||||||
|
return // не можем проверить цель — не дёргаем состояние
|
||||||
|
}
|
||||||
|
|
||||||
|
want := deriveState(sourcePresent, targetPresent)
|
||||||
|
if want == d.State {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.transition(ctx, d, want, "reconcile", reconcileReason(sourcePresent, targetPresent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// debounceSource обновляет счётчик промахов источника и возвращает, считать ли
|
||||||
|
// источник присутствующим с учётом порога. Запись счётчика — только при его
|
||||||
|
// изменении (без лишних UPDATE на каждом тике).
|
||||||
|
func (w *Worker) debounceSource(ctx context.Context, d store.Download, sourceSeen bool) bool {
|
||||||
|
threshold := max(w.cfg.SourceMissingThreshold, 1)
|
||||||
|
if sourceSeen {
|
||||||
|
if d.SourceMissCount != 0 {
|
||||||
|
if err := w.store.SetSourceMissCount(ctx, d.ID, 0); err != nil {
|
||||||
|
logctx.From(ctx).Warn("reconcile reset miss count failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
miss := d.SourceMissCount + 1
|
||||||
|
if err := w.store.SetSourceMissCount(ctx, d.ID, miss); err != nil {
|
||||||
|
logctx.From(ctx).Warn("reconcile bump miss count failed", "error", err)
|
||||||
|
}
|
||||||
|
// До порога источник трактуется как присутствующий — задача не дёргается.
|
||||||
|
return miss < threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetPresent сообщает, существуют ли разложенные хардлинки задачи. Цель
|
||||||
|
// считается присутствующей, только если существуют ВСЕ ссылки последнего
|
||||||
|
// батча; частичная пропажа — это отсутствие цели (библиотека сломана → relink).
|
||||||
|
func (w *Worker) targetPresent(ctx context.Context, id int64) (bool, error) {
|
||||||
|
batch, err := w.store.LatestBatchID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("latest batch: %w", err)
|
||||||
|
}
|
||||||
|
if batch == "" {
|
||||||
|
return false, nil // ничего не разложено — цели нет
|
||||||
|
}
|
||||||
|
rows, err := w.store.ListFileLinksByBatch(ctx, batch)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("list links: %w", err)
|
||||||
|
}
|
||||||
|
laidOut := 0
|
||||||
|
for _, r := range rows {
|
||||||
|
// Все статусы, означающие реальный файл на диске: только что слинкован,
|
||||||
|
// скопирован (фолбэк) или уже существовал тем же inode (идемпотентный
|
||||||
|
// повтор apply). Прочие (collision и т.п.) — не наша разложенная цель.
|
||||||
|
if !isLaidOut(r.Status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
laidOut++
|
||||||
|
if _, err := os.Lstat(r.DstPath); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("stat target %q: %w", r.DstPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return laidOut > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLaidOut сообщает, означает ли статус file_link реально лежащий на ФС файл
|
||||||
|
// нашей раскладки (а не коллизию или иной не-разложенный исход).
|
||||||
|
func isLaidOut(status string) bool {
|
||||||
|
switch layout.LinkStatus(status) {
|
||||||
|
case layout.StatusLinked, layout.StatusCopied, layout.StatusExists:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileReason — человекочитаемая причина перехода для лога/UI.
|
||||||
|
func reconcileReason(sourcePresent, targetPresent bool) string {
|
||||||
|
switch {
|
||||||
|
case sourcePresent && targetPresent:
|
||||||
|
return "источник и цель на месте"
|
||||||
|
case sourcePresent && !targetPresent:
|
||||||
|
return "цель удалена, источник на месте"
|
||||||
|
case !sourcePresent && targetPresent:
|
||||||
|
return "источник удалён, цель (последняя копия) на месте"
|
||||||
|
default:
|
||||||
|
return "удалены и источник, и цель"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Синхронный preflight перед действием (не доверяем state в БД) ---
|
||||||
|
|
||||||
|
// ensureSourcePresent синхронно (без дебаунса) проверяет, что раздача есть в
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
_, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", op, err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Источник пропал — немедленно приводим состояние к реальности.
|
||||||
|
w.reconcileToReality(ctx, *d, false)
|
||||||
|
return fmt.Errorf("%s: источник удалён из qBittorrent: %w", op, ErrConflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileToReality выводит и проставляет состояние по уже известному факту об
|
||||||
|
// источнике (sourcePresent) и фактически проверенной цели. Используется
|
||||||
|
// preflight-проверками: немедленно, без дебаунса.
|
||||||
|
func (w *Worker) reconcileToReality(ctx context.Context, d store.Download, sourcePresent bool) {
|
||||||
|
targetPresent, err := w.targetPresent(ctx, d.ID)
|
||||||
|
if err != nil {
|
||||||
|
logctx.FromOr(ctx, w.log).Warn("preflight target probe failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
want := deriveState(sourcePresent, targetPresent)
|
||||||
|
if want == d.State {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.transition(ctx, d, want, "reconcile", reconcileReason(sourcePresent, targetPresent))
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reconcileFixture готовит done-задачу с одной разложенной ссылкой: цель —
|
||||||
|
// файл в temp-каталоге (создаётся при makeTarget), источник — наличие
|
||||||
|
// раздачи в fakeQbt (sourcePresent).
|
||||||
|
type reconcileFixture struct {
|
||||||
|
w *Worker
|
||||||
|
st *memStore
|
||||||
|
dst string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newReconcileFixture(t *testing.T, state store.State, sourcePresent, makeTarget bool) reconcileFixture {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
dst := filepath.Join(dir, "Movie (2024).mkv")
|
||||||
|
src := filepath.Join(dir, "src.mkv")
|
||||||
|
if makeTarget {
|
||||||
|
if err := os.WriteFile(dst, []byte("video"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
st := newMemStore()
|
||||||
|
d := completedDownload(1)
|
||||||
|
d.State = state
|
||||||
|
st.put(d)
|
||||||
|
st.links = append(st.links, store.FileLink{
|
||||||
|
DownloadID: 1, ApplyBatchID: "b1", SrcPath: src, DstPath: dst,
|
||||||
|
Kind: "video", Status: "linked",
|
||||||
|
})
|
||||||
|
|
||||||
|
var torrents []qbt.Torrent
|
||||||
|
if sourcePresent {
|
||||||
|
torrents = []qbt.Torrent{{Hash: ihTest}}
|
||||||
|
}
|
||||||
|
w := testWorkerWith(st, &fakeQbt{torrents: torrents}, nil, nil)
|
||||||
|
w.cfg.SourceMissingThreshold = 1 // помечаем при первой же пропаже (без задержки)
|
||||||
|
return reconcileFixture{w: w, st: st, dst: dst}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileMatrix(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
source, target bool
|
||||||
|
want store.State
|
||||||
|
}{
|
||||||
|
{"источник+цель → done", true, true, store.StateDone},
|
||||||
|
{"цель удалена → target_missing", true, false, store.StateTargetMissing},
|
||||||
|
{"источник удалён → orphaned", false, true, store.StateOrphaned},
|
||||||
|
{"оба удалены → deleted", false, false, store.StateDeleted},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
f := newReconcileFixture(t, store.StateDone, tc.source, tc.target)
|
||||||
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Poll: %v", err)
|
||||||
|
}
|
||||||
|
if got := f.st.downloads[1].State; got != tc.want {
|
||||||
|
t.Errorf("state = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileHealing(t *testing.T) {
|
||||||
|
// Источник вернулся, цель на месте → orphaned лечится обратно в done.
|
||||||
|
f := newReconcileFixture(t, store.StateOrphaned, true, true)
|
||||||
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Poll: %v", err)
|
||||||
|
}
|
||||||
|
if got := f.st.downloads[1].State; got != store.StateDone {
|
||||||
|
t.Errorf("state = %q, want done (healing)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 {
|
||||||
|
t.Errorf("state = %q, want target_missing (частичная пропажа)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileDebounce(t *testing.T) {
|
||||||
|
// Порог 3: первые два промаха не помечают, третий — помечает orphaned;
|
||||||
|
// возврат источника лечит обратно и сбрасывает счётчик.
|
||||||
|
f := newReconcileFixture(t, store.StateDone, false, true)
|
||||||
|
f.w.cfg.SourceMissingThreshold = 3
|
||||||
|
|
||||||
|
for i := 1; i <= 2; i++ {
|
||||||
|
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 {
|
||||||
|
t.Fatalf("tick %d: state = %q, want done (до порога)", i, got)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
t.Fatalf("tick 3: state = %q, want orphaned (порог достигнут)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Источник вернулся.
|
||||||
|
f.w.qbt.(*fakeQbt).torrents = []qbt.Torrent{{Hash: ihTest}}
|
||||||
|
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 {
|
||||||
|
t.Errorf("state = %q, want done (источник вернулся)", got)
|
||||||
|
}
|
||||||
|
if got := f.st.downloads[1].SourceMissCount; got != 0 {
|
||||||
|
t.Errorf("miss = %d, want 0 (сброс)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileSkipsActiveStates(t *testing.T) {
|
||||||
|
// downloading сверкой не трогаем, даже если раздачи нет в qBittorrent.
|
||||||
|
f := newReconcileFixture(t, store.StateDownloading, false, true)
|
||||||
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Poll: %v", err)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидали отказ Undo для orphaned")
|
||||||
|
}
|
||||||
|
if got := f.st.downloads[1].State; got != store.StateOrphaned {
|
||||||
|
t.Errorf("state = %q, want orphaned (без изменений)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
t.Fatalf("Relink: %v", err)
|
||||||
|
}
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreflightFixesStaleState(t *testing.T) {
|
||||||
|
// В БД target_missing (источник якобы есть), но фактически источник пропал,
|
||||||
|
// а цель на месте: relink немедленно приводит состояние к orphaned, не
|
||||||
|
// дожидаясь фоновой сверки.
|
||||||
|
f := newReconcileFixture(t, store.StateTargetMissing, false, true)
|
||||||
|
if err := f.w.Relink(context.Background(), 1); err == nil {
|
||||||
|
t.Fatal("ожидали отказ relink при пропавшем источнике")
|
||||||
|
}
|
||||||
|
if got := f.st.downloads[1].State; got != store.StateOrphaned {
|
||||||
|
t.Errorf("state = %q, want orphaned (preflight привёл к реальности)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-12
@@ -238,7 +238,10 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
|
|||||||
return fmt.Errorf("apply: lookup torrent: %w", err)
|
return fmt.Errorf("apply: lookup torrent: %w", err)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("apply: torrent not found")
|
// Источник исчез между ревью и применением — приводим состояние к
|
||||||
|
// реальности и отказываем (preflight, не доверяем state в БД).
|
||||||
|
w.reconcileToReality(ctx, *d, false)
|
||||||
|
return fmt.Errorf("apply: источник удалён из qBittorrent: %w", ErrConflict)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.transition(ctx, *d, store.StateLinking, "", "")
|
w.transition(ctx, *d, store.StateLinking, "", "")
|
||||||
@@ -306,17 +309,13 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("relink: %w", err)
|
return fmt.Errorf("relink: %w", err)
|
||||||
}
|
}
|
||||||
if d.State != store.StateReverted && d.State != store.StateCancelled {
|
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): %w", id, d.State, ErrConflict)
|
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
|
||||||
}
|
}
|
||||||
if !d.Infohash.Valid {
|
// Источник нужен для распознавания — проверяем синхронно (без дебаунса) и при
|
||||||
return fmt.Errorf("relink: download %d has no infohash", id)
|
// его отсутствии приводим состояние к реальности (orphaned/deleted).
|
||||||
}
|
if err := w.ensureSourcePresent(ctx, d, "relink"); err != nil {
|
||||||
// Раздача должна ещё быть в qBittorrent — без неё распознавать нечего.
|
return err
|
||||||
if _, ok, terr := w.torrentByInfohash(ctx, d.Infohash.String); terr != nil {
|
|
||||||
return fmt.Errorf("relink: %w", terr)
|
|
||||||
} else if !ok {
|
|
||||||
return fmt.Errorf("relink: торрент не найден в qBittorrent")
|
|
||||||
}
|
}
|
||||||
// Вернуть задачу в активную обработку можно, только если другой активной
|
// Вернуть задачу в активную обработку можно, только если другой активной
|
||||||
// задачи на этот infohash нет (partial unique index по idempotency_key).
|
// задачи на этот infohash нет (partial unique index по idempotency_key).
|
||||||
@@ -348,6 +347,9 @@ func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
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.Infohash.String)
|
||||||
logctx.From(ctx).Info("review re-recognizing without hint")
|
logctx.From(ctx).Info("review re-recognizing without hint")
|
||||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||||
@@ -367,6 +369,9 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
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.Infohash.String)
|
||||||
if err := w.store.AddHint(ctx, id, hint); err != nil {
|
if err := w.store.AddHint(ctx, id, hint); err != nil {
|
||||||
return fmt.Errorf("refine: %w", err)
|
return fmt.Errorf("refine: %w", err)
|
||||||
@@ -389,6 +394,9 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
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.Infohash.String)
|
||||||
if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil {
|
if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil {
|
||||||
return fmt.Errorf("set type: %w", err)
|
return fmt.Errorf("set type: %w", err)
|
||||||
@@ -452,7 +460,9 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Undo снимает хардлинки последнего батча и переводит задачу в reverted.
|
// Undo снимает хардлинки последнего батча и переводит задачу в reverted.
|
||||||
// Источник недосягаем (раскладчик удаляет только пути под библиотекой).
|
// Источник недосягаем (раскладчик удаляет только пути под библиотекой). Откат
|
||||||
|
// снимает ЛИШНИЙ хардлинк, а не последнюю копию: layout.Undo отказывается
|
||||||
|
// удалять ссылку, если источник уже пропал (nlink<=1) — см. state-reconciliation.
|
||||||
func (w *Worker) Undo(ctx context.Context, id int64) error {
|
func (w *Worker) Undo(ctx context.Context, id int64) error {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
@@ -464,6 +474,11 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("undo: %w", err)
|
return fmt.Errorf("undo: %w", err)
|
||||||
}
|
}
|
||||||
|
if d.State == store.StateOrphaned {
|
||||||
|
// Источник удалён → библиотечный хардлинк остался единственной копией.
|
||||||
|
// Откат сотрёт данные насовсем — отказываем явно.
|
||||||
|
return fmt.Errorf("undo: источник удалён, цель — последняя копия данных, откат невозможен: %w", ErrConflict)
|
||||||
|
}
|
||||||
if d.State != store.StateDone {
|
if d.State != store.StateDone {
|
||||||
return fmt.Errorf("undo: download %d is in state %s (expected done): %w", id, d.State, ErrConflict)
|
return fmt.Errorf("undo: download %d is in state %s (expected done): %w", id, d.State, ErrConflict)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,8 @@ func TestRerecognize_ReviewToRecognizing(t *testing.T) {
|
|||||||
d := completedDownload(1)
|
d := completedDownload(1)
|
||||||
d.State = store.StateReview
|
d.State = store.StateReview
|
||||||
st.put(d)
|
st.put(d)
|
||||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
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)
|
t.Fatalf("Rerecognize: %v", err)
|
||||||
@@ -184,8 +185,10 @@ func TestRelink_TorrentMissing(t *testing.T) {
|
|||||||
if err := w.Relink(context.Background(), 1); err == nil {
|
if err := w.Relink(context.Background(), 1); err == nil {
|
||||||
t.Fatal("ожидали ошибку при отсутствии торрента, получили nil")
|
t.Fatal("ожидали ошибку при отсутствии торрента, получили nil")
|
||||||
}
|
}
|
||||||
if st.downloads[1].State != store.StateReverted {
|
// Preflight приводит состояние к реальности: источника нет и цели нет
|
||||||
t.Errorf("state = %q, want reverted (без изменений)", st.downloads[1].State)
|
// (reverted — ссылки сняты) → deleted (см. state-reconciliation).
|
||||||
|
if st.downloads[1].State != store.StateDeleted {
|
||||||
|
t.Errorf("state = %q, want deleted (preflight привёл к реальности)", st.downloads[1].State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +294,13 @@ func (m *memStore) SetDownloadState(_ context.Context, id int64, st store.State,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *memStore) SetSourceMissCount(_ context.Context, id int64, n int) error {
|
||||||
|
if d, ok := m.downloads[id]; ok {
|
||||||
|
d.SourceMissCount = n
|
||||||
|
}
|
||||||
|
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) (int64, error) {
|
||||||
for _, e := range m.recs {
|
for _, e := range m.recs {
|
||||||
if e.DownloadID == r.DownloadID {
|
if e.DownloadID == r.DownloadID {
|
||||||
@@ -578,7 +588,8 @@ func TestRefine_AddsHintAndRerecognizes(t *testing.T) {
|
|||||||
d := completedDownload(1)
|
d := completedDownload(1)
|
||||||
d.State = store.StateReview
|
d.State = store.StateReview
|
||||||
st.put(d)
|
st.put(d)
|
||||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
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)
|
t.Fatalf("Refine: %v", err)
|
||||||
@@ -599,7 +610,8 @@ func TestSetType(t *testing.T) {
|
|||||||
d := completedDownload(1)
|
d := completedDownload(1)
|
||||||
d.State = store.StateReview
|
d.State = store.StateReview
|
||||||
st.put(d)
|
st.put(d)
|
||||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
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)
|
t.Fatalf("SetType: %v", err)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type Store interface {
|
|||||||
ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error)
|
ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error)
|
||||||
GetDownload(ctx context.Context, id int64) (*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
|
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
|
||||||
|
SetSourceMissCount(ctx context.Context, id int64, n int) error
|
||||||
|
|
||||||
// Discovery (усыновление раздач по категории/тегу).
|
// Discovery (усыновление раздач по категории/тегу).
|
||||||
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
|
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
|
||||||
@@ -90,6 +91,8 @@ type NotifyEvent string
|
|||||||
const (
|
const (
|
||||||
EventReview NotifyEvent = "review" // задача ждёт подтверждения
|
EventReview NotifyEvent = "review" // задача ждёт подтверждения
|
||||||
EventDone NotifyEvent = "done" // раскладка завершена
|
EventDone NotifyEvent = "done" // раскладка завершена
|
||||||
|
EventOrphaned NotifyEvent = "orphaned" // источник пропал, цель — последняя копия
|
||||||
|
EventTargetMissing NotifyEvent = "target_missing" // цель удалена, доступен relink
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
|
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
|
||||||
@@ -113,6 +116,9 @@ type Config struct {
|
|||||||
PollInterval time.Duration
|
PollInterval time.Duration
|
||||||
StuckAfter time.Duration // stalledDL дольше → stuck
|
StuckAfter time.Duration // stalledDL дольше → stuck
|
||||||
MagnetTimeout time.Duration // metaDL дольше → failed
|
MagnetTimeout time.Duration // metaDL дольше → failed
|
||||||
|
// SourceMissingThreshold — порог дебаунса пропажи источника (тиков сверки).
|
||||||
|
// <1 трактуется как 1 (помечаем при первой же устойчивой пропаже).
|
||||||
|
SourceMissingThreshold int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Worker — поллер и владелец переходов.
|
// Worker — поллер и владелец переходов.
|
||||||
@@ -235,6 +241,10 @@ func (w *Worker) Poll(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
w.reconcile(ctx, d, t)
|
w.reconcile(ctx, d, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сверка разложенных задач с реальностью (источник в qBit + хардлинки на ФС)
|
||||||
|
// — отдельно от активных, по двумерной матрице (см. state-reconciliation).
|
||||||
|
w.reconcileDesync(ctx, byHash)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +303,10 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
|
|||||||
go w.notifier.Notify(context.Background(), d.ID, EventReview)
|
go w.notifier.Notify(context.Background(), d.ID, EventReview)
|
||||||
case store.StateDone:
|
case store.StateDone:
|
||||||
go w.notifier.Notify(context.Background(), d.ID, EventDone)
|
go w.notifier.Notify(context.Background(), d.ID, EventDone)
|
||||||
|
case store.StateOrphaned:
|
||||||
|
go w.notifier.Notify(context.Background(), d.ID, EventOrphaned)
|
||||||
|
case store.StateTargetMissing:
|
||||||
|
go w.notifier.Notify(context.Background(), d.ID, EventTargetMissing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetSourceMissCount(_ context.Context, id int64, n int) error {
|
||||||
|
d, ok := f.downloads[id]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("download %d not found", id)
|
||||||
|
}
|
||||||
|
d.SourceMissCount = n
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
|
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
|
||||||
|
|
||||||
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) {
|
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-29
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Сверка распознавания (`internal/recognize/metadata.go`, `matchMetadata`)
|
||||||
|
сейчас формирует один поисковый ключ `searchTitle = provider_hint || title`
|
||||||
|
и шлёт его всем включённым провайдерам. Гейт `strongMatches` сравнивает
|
||||||
|
нормализованные `{plan.Title, plan.OriginalTitle}` против
|
||||||
|
`{cand.Title, cand.OriginalTitle}` и требует год ±1. Базы TMDB/TVDB
|
||||||
|
индексированы прежде всего по оригинальным названиям, поэтому русское
|
||||||
|
релиз-имя часто не находится, а если `original_title` пуст — английская
|
||||||
|
сторона гейта вообще не работает. TMDB-поиск не передаёт `language`, так что
|
||||||
|
локализованный `Title` приходит в дефолтной локали.
|
||||||
|
|
||||||
|
Источник истины по recognition пока `docs/specs/recognition.md` (capability
|
||||||
|
ещё не перенесена в OpenSpec) — дельту синхронизируем с ним.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Повысить попадаемость сверки на иностранных фильмах с русским релиз-именем,
|
||||||
|
не ослабляя гейты авто-раскладки.
|
||||||
|
- Сделать оригинальное название всегда доступным для запроса и сравнения.
|
||||||
|
- Минимальные, локальные правки в существующих функциях.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Не меняем модель уверенности и условия авто (матч в базе + структурная
|
||||||
|
валидация + согласованность сигналов остаются как есть).
|
||||||
|
- Не вводим fuzzy-сравнение названий (только точечная нормализация `ё`→`е`).
|
||||||
|
- Не добавляем поле страны/языка происхождения в `Plan`.
|
||||||
|
- Не трогаем TVDB/TVMaze-клиентов по части локали (вне объёма).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
**1. Стратегия поиска «оригинал → fallback», а не мёрж всех запросов.**
|
||||||
|
Перебираем ключи `[original_title, title, provider_hint]` по порядку,
|
||||||
|
останавливаемся на первом, давшем единичный сильный матч. Оригинал —
|
||||||
|
сильнейший ключ баз, обычно хватает первого захода; меньше обращений к
|
||||||
|
API/квотам. Альтернатива — гнать все запросы и мёржить кандидатов — даёт чуть
|
||||||
|
полнее список для review, но дороже по обращениям; отвергнута как избыточная.
|
||||||
|
Кандидатов для review всё равно копим из всех фактически выполненных заходов.
|
||||||
|
Дубль-ключи (нормализованно равные уже выполненному) пропускаем.
|
||||||
|
|
||||||
|
**2. `provider_hint` остаётся третьим фолбэком, а не удаляется.**
|
||||||
|
Два канонических названия покрывают основной кейс, но `hint` иногда
|
||||||
|
сформулирован удачнее (очищен от мусора релиз-имени) — дешёвая страховка,
|
||||||
|
когда оба названия не нашлись. Удаление поля — лишняя правка схемы без явной
|
||||||
|
выгоды.
|
||||||
|
|
||||||
|
**3. «Всегда заполнять оба названия» — через промпт, не через жёсткую схему.**
|
||||||
|
Требование к модели: заполнять `title` и `original_title`, при отсутствии
|
||||||
|
отдельного оригинала или для российского контента — дублировать `title`; при
|
||||||
|
неуверенности — **дублировать, а не выдумывать**. Это и есть защита от
|
||||||
|
ложного авто-матча: «не знаю» схлопывается в безопасное дублирование русского
|
||||||
|
названия, а не в галлюцинацию английского, которая могла бы случайно
|
||||||
|
сматчиться с реальным фильмом и привести к авто-раскладке не того тайтла.
|
||||||
|
`parsePlan` остаётся мягким: пустой `original_title` не отбраковываем
|
||||||
|
(graceful-фолбэк на `title`), чтобы не плодить correction-ретраи и не уходить
|
||||||
|
в review зря.
|
||||||
|
|
||||||
|
**4. `language=ru-RU` для TMDB, всегда, параметризуемо конфигом.**
|
||||||
|
Поле `original_title`/`original_name` у TMDB не зависит от `language` —
|
||||||
|
английская сторона гейта не страдает. Локализованный `Title` приходит
|
||||||
|
по-русски: сходится русская сторона гейта и аккуратнее карточки кандидатов в
|
||||||
|
review. Деление на «российский/нероссийский» не нужно — `ru-RU` полезен
|
||||||
|
именно зарубежке, а для русского контента нейтрален. Дефолт `ru-RU`, поле
|
||||||
|
`[metadata.tmdb].language`.
|
||||||
|
|
||||||
|
**5. `ё`→`е` в `normalize`, и только это.**
|
||||||
|
Узкая правка под реальный класс расхождений написания. `й`→`и` и прочие
|
||||||
|
свёртки НЕ делаем — меняют смысл, риск ложных совпадений.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Модель всё же выдумывает оригинал вопреки промпту** → гейт по-прежнему
|
||||||
|
требует год ±1 и единичность матча; авто только при подтверждённом матче.
|
||||||
|
Промпт явно предписывает дублирование при неуверенности. Остаточный риск
|
||||||
|
низкий и не выше текущего (галлюцинация `title` возможна и сейчас).
|
||||||
|
- **`ru-RU` для контента без русской локализации** → TMDB отдаёт fallback
|
||||||
|
(оригинал/английский), хуже текущего поведения не становится.
|
||||||
|
- **Лишние обращения к базам при фолбэках** → ограничены порядком из 1–3
|
||||||
|
запросов на провайдера, дубль-ключи пропускаются, ранний стоп на первом
|
||||||
|
сильном матче. Ошибки провайдера по-прежнему не валят распознавание.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
- Изменения обратносовместимы по хранимым данным и API. Новое поле конфига
|
||||||
|
`[metadata.tmdb].language` опционально (дефолт `ru-RU`).
|
||||||
|
- Откат — ревёрт; персистентных миграций БД нет.
|
||||||
|
- После apply синхронизировать `docs/specs/recognition.md` с дельтой.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Нет.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Сверка распознавания с базой метаданных промахивается на иностранных
|
||||||
|
фильмах с русским релиз-именем: реальный кейс — «Тёмный рыцарь» (The Dark
|
||||||
|
Knight), поиск не нашёл ничего, раскладку пришлось делать вручную. Базы
|
||||||
|
(TMDB/TVDB) индексированы прежде всего по оригинальным названиям, а поиск
|
||||||
|
сейчас идёт по одной строке `provider_hint || title` и игнорирует
|
||||||
|
`original_title`. Из-за этого сильное звено сверки — оригинальное название —
|
||||||
|
не используется ни в запросе, ни (когда модель оставила его пустым) в гейте
|
||||||
|
сравнения.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Поиск по нескольким названиям** в сверке с базой: стратегия
|
||||||
|
«оригинал → fallback» — сначала запрос по `original_title`, при единичном
|
||||||
|
сильном матче стоп; иначе запрос по локализованному `title`; третьим
|
||||||
|
фолбеком — `provider_hint`. Кандидатов для review копим из всех заходов
|
||||||
|
(дедуп по `provider:id`). Избыточный запрос-дубль (когда нормализованные
|
||||||
|
названия совпадают) пропускаем.
|
||||||
|
- **Контракт LLM на оба названия:** промпт требует всегда заполнять и
|
||||||
|
`title`, и `original_title`. Нет отдельного оригинала / российское
|
||||||
|
происхождение → продублировать `title`. Не уверен в оригинале → дублировать,
|
||||||
|
а **не выдумывать** (защита от ложного авто-матча по галлюцинации). Схема
|
||||||
|
разбора остаётся мягкой: пустой `original_title` не отбраковываем, а
|
||||||
|
graceful-фолбэк на `title`.
|
||||||
|
- **Локаль TMDB:** TMDB-поиск всегда передаёт `language` (по умолчанию
|
||||||
|
`ru-RU`), параметризуемый конфигом. Локализованный `Title` приходит
|
||||||
|
по-русски — сходится русская сторона гейта и аккуратнее карточки кандидатов
|
||||||
|
в review. `original_title` у TMDB остаётся на языке оригинала, английская
|
||||||
|
сторона гейта не страдает.
|
||||||
|
- **Нормализация названий:** в сравнении сводим `ё`→`е`, чтобы «Тёмный» и
|
||||||
|
«Темный» считались одним названием.
|
||||||
|
|
||||||
|
Гейты авто-раскладки не ослабляются: авто по-прежнему только при
|
||||||
|
подтверждённом единичном матче в базе + структурной валидации +
|
||||||
|
согласованности сигналов. Изменение лишь повышает попадаемость сверки.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `recognition`: распознавание контента раздачи (фильм/сериал, название,
|
||||||
|
год, сезон/серия), сверка с базами метаданных и решение «авто или review».
|
||||||
|
Первый перенос capability из `docs/specs/recognition.md` в OpenSpec; в этот
|
||||||
|
change фиксируем требования к сверке с базой и контракту названий,
|
||||||
|
затронутые изменением (остальное мигрируется отдельно).
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
<!-- Нет: recognition в OpenSpec ещё не было, заводим как новую. -->
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `internal/recognize/metadata.go` — `matchMetadata` (многозапросный поиск),
|
||||||
|
`normalize` (`ё`→`е`).
|
||||||
|
- `internal/recognize/prompt.go` — правила промпта по `title`/`original_title`.
|
||||||
|
- `internal/metadata/tmdb.go` — параметр `language` в запросе поиска.
|
||||||
|
- Конфигурация — новое поле `[metadata.tmdb].language` (дефолт `ru-RU`),
|
||||||
|
валидация на старте.
|
||||||
|
- `docs/specs/recognition.md` — синхронизируем с дельтой (источник истины до
|
||||||
|
полного переноса).
|
||||||
|
- Внешнее API/схема ответа LLM: `provider_hint` сохраняется; новые требования
|
||||||
|
к заполнению `original_title`. Обратная совместимость хранимых данных не
|
||||||
|
затрагивается.
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Сверка с базой по нескольким названиям
|
||||||
|
|
||||||
|
При сверке плана с включёнными базами метаданных система SHALL искать по
|
||||||
|
нескольким названиям в порядке убывания силы ключа: сначала по
|
||||||
|
`original_title`, затем по локализованному `title`, затем по `provider_hint`.
|
||||||
|
Поиск SHALL останавливаться, как только очередной запрос дал единичный
|
||||||
|
сильный матч (ровно один кандидат с совпадением названия и года). Запрос с
|
||||||
|
названием, нормализованно совпадающим с уже выполненным, система SHALL
|
||||||
|
пропускать, чтобы не обращаться к базе повторно с тем же ключом.
|
||||||
|
|
||||||
|
Кандидаты для ручного выбора в review система SHALL собирать из всех
|
||||||
|
выполненных заходов с дедупликацией по `provider:id` и общим потолком.
|
||||||
|
|
||||||
|
#### Scenario: Иностранный фильм находится по оригинальному названию
|
||||||
|
|
||||||
|
- **GIVEN** план с `title` «Тёмный рыцарь», `original_title` «The Dark Knight», год 2008
|
||||||
|
- **WHEN** выполняется сверка с базой
|
||||||
|
- **THEN** первый запрос идёт по «The Dark Knight»
|
||||||
|
- **AND** при единичном сильном матче дальнейшие запросы (по `title`, `provider_hint`) не выполняются
|
||||||
|
|
||||||
|
#### Scenario: Фолбэк на локализованное название
|
||||||
|
|
||||||
|
- **GIVEN** план, для которого запрос по `original_title` не дал единичного сильного матча
|
||||||
|
- **WHEN** продолжается сверка
|
||||||
|
- **THEN** выполняется запрос по локализованному `title`
|
||||||
|
- **AND** при отсутствии матча и там — запрос по `provider_hint`
|
||||||
|
|
||||||
|
#### Scenario: Дублирующий запрос пропускается
|
||||||
|
|
||||||
|
- **GIVEN** план, у которого `original_title` нормализованно совпадает с `title`
|
||||||
|
- **WHEN** выполняется сверка
|
||||||
|
- **THEN** база запрашивается этим названием один раз, повторный заход по `title` не делается
|
||||||
|
|
||||||
|
### Requirement: Контракт LLM на оригинальное и локализованное названия
|
||||||
|
|
||||||
|
Промпт распознавания SHALL требовать от модели всегда заполнять и `title`, и
|
||||||
|
`original_title`. Если отдельного оригинального названия нет или контент
|
||||||
|
российского происхождения, модель SHALL дублировать `title` в
|
||||||
|
`original_title`. При неуверенности в оригинальном названии модель SHALL
|
||||||
|
дублировать `title`, а не выдумывать название (защита от ложного авто-матча).
|
||||||
|
|
||||||
|
Разбор ответа SHALL оставаться устойчивым к пустому `original_title`: пустое
|
||||||
|
значение не отбраковывается и не вызывает correction-ретрай; сверка
|
||||||
|
gracefully использует доступные названия.
|
||||||
|
|
||||||
|
#### Scenario: Российский фильм — дублирование
|
||||||
|
|
||||||
|
- **GIVEN** раздача российского фильма без отдельного оригинального названия
|
||||||
|
- **WHEN** модель возвращает план
|
||||||
|
- **THEN** `title` и `original_title` заполнены одинаковым каноническим названием
|
||||||
|
|
||||||
|
#### Scenario: Пустой original_title не ломает разбор
|
||||||
|
|
||||||
|
- **GIVEN** ответ модели с пустым `original_title`
|
||||||
|
- **WHEN** план разбирается
|
||||||
|
- **THEN** разбор успешен без correction-ретрая
|
||||||
|
- **AND** сверка использует `title` (и `provider_hint`)
|
||||||
|
|
||||||
|
### Requirement: Локаль запроса к TMDB
|
||||||
|
|
||||||
|
Запрос поиска к TMDB SHALL передавать параметр `language`, по умолчанию
|
||||||
|
`ru-RU`, со значением, настраиваемым конфигом `[metadata.tmdb].language`.
|
||||||
|
Это влияет только на локализованное поле `Title`/`Name`; поле
|
||||||
|
`original_title`/`original_name` остаётся на языке оригинала, поэтому
|
||||||
|
оригинальная сторона сравнения не затрагивается.
|
||||||
|
|
||||||
|
#### Scenario: Локализованный заголовок приходит по-русски
|
||||||
|
|
||||||
|
- **GIVEN** TMDB включён, `language` не задан в конфиге
|
||||||
|
- **WHEN** выполняется поиск фильма с русской локализацией
|
||||||
|
- **THEN** запрос содержит `language=ru-RU`
|
||||||
|
- **AND** в кандидате `Title` приходит на русском, а `OriginalTitle` — на языке оригинала
|
||||||
|
|
||||||
|
### Requirement: Нормализация названий при сравнении
|
||||||
|
|
||||||
|
Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`,
|
||||||
|
чтобы написания, различающиеся только `ё`/`е`, считались одним названием.
|
||||||
|
|
||||||
|
#### Scenario: «Тёмный» и «Темный» совпадают
|
||||||
|
|
||||||
|
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
|
||||||
|
- **WHEN** сравниваются нормализованные названия
|
||||||
|
- **THEN** они считаются совпадающими
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
## 1. Конфигурация TMDB language
|
||||||
|
|
||||||
|
- [x] 1.1 Добавить поле `language` в конфиг TMDB (`[metadata.tmdb].language`), дефолт `ru-RU`; проброс в клиент TMDB
|
||||||
|
- [x] 1.2 Безопасный дефолт `ru-RU` при пустой локали (в `Default()` и фолбэком в клиенте — отдельная валидация-реджект не нужна); обновить `config.example.toml`
|
||||||
|
|
||||||
|
## 2. Локаль в TMDB-клиенте
|
||||||
|
|
||||||
|
- [x] 2.1 В `internal/metadata/tmdb.go` передавать `language` в `Search` (`params.Set("language", ...)`)
|
||||||
|
- [x] 2.2 Тест: запрос содержит `language=ru-RU`; `OriginalTitle` не зависит от локали
|
||||||
|
|
||||||
|
## 3. Нормализация названий
|
||||||
|
|
||||||
|
- [x] 3.1 В `internal/recognize/metadata.go` `normalize` сводить `ё`→`е`
|
||||||
|
- [x] 3.2 Тест: «Тёмный рыцарь» и «Темный рыцарь» нормализуются одинаково
|
||||||
|
|
||||||
|
## 4. Многозапросный поиск в matchMetadata
|
||||||
|
|
||||||
|
- [x] 4.1 Сформировать упорядоченный список ключей `[original_title, title, provider_hint]`, отбросив пустые и нормализованные дубли
|
||||||
|
- [x] 4.2 Перебирать ключи: для каждого — поиск по всем провайдерам, ранний стоп на первом единичном сильном матче (`strongMatches`)
|
||||||
|
- [x] 4.3 Кандидатов для review копить из всех выполненных заходов (дедуп по `provider:id`, потолок `maxCandidates`)
|
||||||
|
- [x] 4.4 Тесты: матч по original при пустом совпадении по title; фолбэк на title; фолбэк на provider_hint; пропуск дубль-ключа
|
||||||
|
|
||||||
|
## 5. Контракт LLM на названия (промпт)
|
||||||
|
|
||||||
|
- [x] 5.1 В `internal/recognize/prompt.go` усилить правила: всегда заполнять `title` и `original_title`; дублировать при отсутствии оригинала / российском происхождении; при неуверенности дублировать, не выдумывать
|
||||||
|
- [x] 5.2 Убедиться, что `parsePlan` остаётся мягким к пустому `original_title` (нет отбраковки/лишнего correction-ретрая); тест на graceful-фолбэк
|
||||||
|
|
||||||
|
## 6. Синхронизация спеки и проверка
|
||||||
|
|
||||||
|
- [x] 6.1 Синхронизировать `docs/specs/recognition.md` с дельтой (конвейер сверки, контракт названий, локаль TMDB, нормализация)
|
||||||
|
- [x] 6.2 `task test` и `task lint` зелёные
|
||||||
|
- [x] 6.3 `openspec validate recognition-multi-title-match --strict` проходит
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-29
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
`worker` ведёт FSM загрузки (см. [workflow.md](../../../docs/specs/workflow.md)).
|
||||||
|
Сейчас `Poll` сверяет с qBittorrent только задачи в `downloading`
|
||||||
|
(`ListDownloadsByState(StateDownloading)`); терминальные задачи (`done`) с
|
||||||
|
реальностью не сверяются вовсе. Если раздача исчезла из qBittorrent, для
|
||||||
|
активной задачи код лишь пишет `Warn("active download not found")`; для
|
||||||
|
`done` не делает ничего.
|
||||||
|
|
||||||
|
Удаление просмотренного контента — штатная эксплуатационная операция, и оно
|
||||||
|
ручное: из qBittorrent (источник + скачанные файлы) или из Jellyfin (наши
|
||||||
|
хардлинки). Два инварианта при этом ломаются молча:
|
||||||
|
|
||||||
|
- **«Источник неприкосновенен»** перестаёт держаться, когда источника уже
|
||||||
|
нет: библиотечный хардлинк становится последней копией, а `layout.Undo`
|
||||||
|
безусловно `unlink`-ает цель.
|
||||||
|
- **Состояние `done` правдиво** перестаёт держаться, когда файлы убрали из
|
||||||
|
библиотеки: ссылок нет, а БД числит `done`.
|
||||||
|
|
||||||
|
Данные, на которые опираемся: `download.infohash` (сопоставление с qBit),
|
||||||
|
`file_link.dst_path` + `status` (разложенные хардлинки), `file_link.src_path`
|
||||||
|
(исходный файл раздачи). qBittorrent уже листаем целиком в `Poll`.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Распознавать ручное удаление источника и/или цели фоновой сверкой и
|
||||||
|
отражать его в состоянии задачи — **без автоматических действий**.
|
||||||
|
- Не допускать потери данных при `Undo`, когда источник уже удалён.
|
||||||
|
- Сделать рассинхрон видимым (состояние в UI + уведомление автору).
|
||||||
|
- Сохранить самовосстановление: вернулась реальность — вернулось состояние.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Удаление средствами jellybit (path 2, «единое окно»).
|
||||||
|
- Автоперезапуск распознавания/раскладки при рассинхроне (relink — вручную).
|
||||||
|
- Реакция на удаление файлов **внутри** живой раздачи (это `missingFiles`/
|
||||||
|
`error` qBittorrent — уже ведёт в `failed`).
|
||||||
|
- Ретеншн терминальных задач.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1. Двумерная матрица «источник × цель» → новые состояния FSM
|
||||||
|
|
||||||
|
Рассинхрон параметризуется двумя независимыми фактами. Для задачи, которая
|
||||||
|
дошла до раскладки, состояние выводится из них:
|
||||||
|
|
||||||
|
| источник (qBit) | цель (хардлинки) | состояние |
|
||||||
|
|-----------------|------------------|-----------------|
|
||||||
|
| есть | есть | `done` |
|
||||||
|
| есть | нет | `target_missing`|
|
||||||
|
| нет | есть | `orphaned` |
|
||||||
|
| нет | нет | `deleted` |
|
||||||
|
|
||||||
|
Выбрали **новые значения `state`**, а не флаги поверх состояния:
|
||||||
|
терминальность и набор доступных команд у этих ситуаций разные, а FSM —
|
||||||
|
единая точка правды (граф уже в `workflow.md`). Флаги размазали бы логику
|
||||||
|
«что можно делать» по двум осям.
|
||||||
|
|
||||||
|
- `target_missing` — источник на месте → доступен пользовательский relink
|
||||||
|
(та же команда «Привязать заново», что из `reverted`/`cancelled`:
|
||||||
|
`target_missing → recognizing`). Авто-переход в `recognizing` **не**
|
||||||
|
делаем — это нарушило бы «никаких автодействий».
|
||||||
|
- `orphaned` — источник пропал, цель (последняя копия) на месте. Команд
|
||||||
|
вперёд нет; `Undo` заблокирован (см. D4).
|
||||||
|
- `deleted` — пусто и там, и там; терминально, действий нет.
|
||||||
|
|
||||||
|
**Альтернатива (отклонено):** одно состояние `desynced` + поле-причина.
|
||||||
|
Хуже: разные исходы требуют разных команд и разной терминальности — проще
|
||||||
|
развести по состояниям.
|
||||||
|
|
||||||
|
### D2. Какие задачи и как сверяем
|
||||||
|
|
||||||
|
Desync-сверка применяется только к задачам, для которых ожидаем разложенные
|
||||||
|
файлы и осмысленную связь с источником: `done`, `target_missing`,
|
||||||
|
`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/
|
||||||
|
`deferred`/`linking`) и пользовательски-терминальные (`reverted`/
|
||||||
|
`cancelled`/`failed`/`stuck`) сверка **не трогает** (`reverted` = мы сами
|
||||||
|
сняли ссылки, это не рассинхрон).
|
||||||
|
|
||||||
|
- **Источник присутствует** ⟺ `download.infohash` найден среди торрентов
|
||||||
|
qBittorrent (карту `byHash` `Poll` уже строит).
|
||||||
|
- **Цель присутствует** ⟺ все `file_link` со `status = linked` существуют на
|
||||||
|
ФС (`os.Lstat`). Частичная пропажа (исчезла часть ссылок) трактуется как
|
||||||
|
«цель отсутствует» → `target_missing` (библиотека сломана, лечится
|
||||||
|
relink/слиянием).
|
||||||
|
|
||||||
|
Сверку делаем на том же тике `Poll` (5 с). При текущих объёмах (домашний
|
||||||
|
сервер) `Lstat` по ссылкам терминальных задач дёшев; если объём вырастет
|
||||||
|
(см. TODO «Ретеншн»), вынесем desync-сверку на отдельный, более редкий
|
||||||
|
интервал. Состояние выводим и переписываем только при изменении (без
|
||||||
|
лишних записей и логов на каждом тике).
|
||||||
|
|
||||||
|
### D3. Дебаунс пропажи источника
|
||||||
|
|
||||||
|
qBittorrent может временно пропасть из выдачи (рестарт демона, мигнул API),
|
||||||
|
тогда как локальный `Lstat` цели надёжен. Поэтому дебаунсим **только
|
||||||
|
отсутствие источника**:
|
||||||
|
|
||||||
|
- новый столбец `download.source_miss_count` (миграция goose);
|
||||||
|
- источник отсутствует на тике → `source_miss_count++`; найден →
|
||||||
|
сбрасываем в `0`;
|
||||||
|
- источник считается удалённым (для вывода состояния) лишь когда
|
||||||
|
`source_miss_count >= [worker].source_missing_threshold` (по умолчанию
|
||||||
|
`3` → ~15 с при интервале 5 с). До порога источник трактуется как
|
||||||
|
присутствующий — задача не дёргается.
|
||||||
|
|
||||||
|
Отсутствие цели в дебаунсе не нуждается (локальная ФС не «мигает»).
|
||||||
|
|
||||||
|
### D4. Безопасный `Undo`: не снимать последнюю копию
|
||||||
|
|
||||||
|
`layout.Undo` для каждой ссылки перед `unlink`:
|
||||||
|
|
||||||
|
1. `Lstat(dst)` — нет файла → нечего снимать, пропускаем (идемпотентно).
|
||||||
|
2. Иначе читаем `nlink` цели. `nlink <= 1` означает: это **единственная**
|
||||||
|
ссылка на inode (источник уже удалён) — `unlink` сотрёт данные. Отказ:
|
||||||
|
не трогаем файл, копим причину.
|
||||||
|
3. Доп. явный сигнал: `src_path` не существует → тоже отказ (источника нет).
|
||||||
|
|
||||||
|
Отказ возвращается типизированной ошибкой; задача в `reverted` **не**
|
||||||
|
переходит, причина показывается пользователю. На командном уровне `Undo`
|
||||||
|
для задачи в `orphaned` отклоняется сразу (по определению источник удалён →
|
||||||
|
все ссылки — последние копии); per-file `nlink`-проверка остаётся страховкой
|
||||||
|
на случай устаревшего состояния. Откат снимает **лишний** хардлинк, а не
|
||||||
|
последнюю копию.
|
||||||
|
|
||||||
|
### D5. Синхронный preflight перед действием (не доверяем `state` в БД)
|
||||||
|
|
||||||
|
Фоновая сверка (D2/D3) — eventual: она отстаёт на интервал поллинга плюс
|
||||||
|
дебаунс. Поэтому любая **команда**, которой нужен источник или цель, делает
|
||||||
|
собственную **синхронную пробу** прямо перед действием, а не полагается на
|
||||||
|
значение `state`:
|
||||||
|
|
||||||
|
- `relink`/«Распознать заново»/«Уточнить» и `Apply` (раскладка) — требуют
|
||||||
|
источника (раздача в qBittorrent);
|
||||||
|
- `Undo` — требует источника (чтобы не снять последнюю копию).
|
||||||
|
|
||||||
|
Чтобы не дублировать логику, выделяем чистый помощник
|
||||||
|
`probe(download) → (sourcePresent, targetPresent)` и `deriveState(...)`,
|
||||||
|
которые используют **и** фоновая сверка, **и** preflight — единая точка
|
||||||
|
правды о том, что есть на диске и как это отображается в состояние.
|
||||||
|
|
||||||
|
Ключевое отличие preflight от фона: **без дебаунса** — это явное действие
|
||||||
|
пользователя «сейчас», единичная проба. Если qBittorrent в этот момент
|
||||||
|
недоступен, команда честно отказывает («источник недоступен») — пользователь
|
||||||
|
повторит. Дебаунс нужен только фону, чтобы не дёргать состояние на
|
||||||
|
транзиентных пропажах.
|
||||||
|
|
||||||
|
При неуспехе предусловия команда не выполняет действие, прогоняет
|
||||||
|
`deriveState` (приводя `state` к реальности — напр. `done → orphaned`) и
|
||||||
|
возвращает пользователю причину. Так команда сама «лечит» устаревшее
|
||||||
|
состояние, не дожидаясь `worker`.
|
||||||
|
|
||||||
|
### D6. Самовосстановление (healing)
|
||||||
|
|
||||||
|
Состояние всегда выводится из текущей матрицы D1 (с учётом дебаунса D3), а
|
||||||
|
не «залипает». Если источник вернулся (раздачу добавили заново) или цель
|
||||||
|
снова на месте — следующая сверка переведёт задачу обратно
|
||||||
|
(`orphaned/target_missing/deleted → done`). `deleted` не делаем абсорбирующим
|
||||||
|
ради единообразия; на практике одновременный возврат маловероятен.
|
||||||
|
|
||||||
|
### D7. Видимость
|
||||||
|
|
||||||
|
При переходе в `orphaned`/`target_missing` `worker` шлёт уведомление автору
|
||||||
|
через существующий `notifier` (новые события `EventOrphaned`/
|
||||||
|
`EventTargetMissing`), как для `review`/`done`. Web-UI и Telegram
|
||||||
|
отображают новые состояния; для `orphaned` кнопка `Undo` скрыта/заблокирована
|
||||||
|
с пояснением «источник удалён — это последняя копия».
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Ложная пометка при долгом простое qBittorrent** (рестарт дольше
|
||||||
|
`threshold × poll_interval`) → дебаунс D3 + самовосстановление D6: вернётся
|
||||||
|
раздача — вернётся `done`. Порог настраивается.
|
||||||
|
- **Стоимость `Lstat` на каждом тике** при росте числа терминальных задач →
|
||||||
|
при текущих объёмах пренебрежимо; путь отхода — отдельный редкий интервал
|
||||||
|
desync-сверки (зафиксировано в D2, делаем при необходимости).
|
||||||
|
- **`nlink` зависит от ФС/синтаксиса `syscall.Stat_t`** (Linux-таргет,
|
||||||
|
`CGO_ENABLED=0`, `linux/amd64`) → платформа фиксирована деплоем; copy-fallback
|
||||||
|
раскладки (не хардлинк) даёт `nlink==1` у легитимной копии — такой `Undo`
|
||||||
|
тоже корректно откажет (это и есть единственная копия). Приемлемо: лучше
|
||||||
|
отказать, чем удалить данные.
|
||||||
|
- **Гонки команда/сверка** → всё под `w.mu` per-download, как и остальные
|
||||||
|
переходы; новых блокировок не вводим.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
- Миграция goose `0003_*`: `ALTER TABLE download ADD COLUMN
|
||||||
|
source_miss_count INTEGER NOT NULL DEFAULT 0`. Новые значения `state` —
|
||||||
|
данных не мигрируют (аддитивно).
|
||||||
|
- Конфиг: новый ключ `[worker].source_missing_threshold` с дефолтом; старые
|
||||||
|
конфиги валидны без него.
|
||||||
|
- Откат: безопасен — состояния перестанут проставляться, существующие
|
||||||
|
desync-задачи останутся со своим значением `state` (UI покажет как
|
||||||
|
неизвестное/как есть). Столбец можно не удалять.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Нужны ли пользователю команды из `orphaned`, кроме как ждать
|
||||||
|
восстановления (например явное «Забыть»/перевод в `deleted` руками)? На
|
||||||
|
старте — нет, только пометка; добавим, если будет спрос.
|
||||||
|
- Порог дебаунса по умолчанию (`3`) — уточнить по реальному времени
|
||||||
|
рестарта qBittorrent на umbar.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Пришло время удалять просмотренные фильмы/сериалы, чтобы освобождать место
|
||||||
|
под новые. Удаляют их **вручную** — из qBittorrent (источник) или из
|
||||||
|
Jellyfin (целевые хардлинки). Сейчас jellybit этого не замечает: `worker`
|
||||||
|
поллит только задачи в `downloading`, терминальные задачи (`done`) с
|
||||||
|
реальностью не сверяет. Состояние в БД молча расходится с диском:
|
||||||
|
|
||||||
|
- **Источник пропал, цель осталась.** qBittorrent стирает скачанные файлы
|
||||||
|
при удалении раздачи. Хардлинк в библиотеке становится **последней**
|
||||||
|
ссылкой на inode, а обычный `Undo` (`unlink` цели) сотрёт единственную
|
||||||
|
копию — прямая потеря данных. Инвариант «источник неприкосновенен»
|
||||||
|
молчаливо перестаёт держаться: источника уже нет.
|
||||||
|
- **Цель пропала, источник остался.** Файлы убрали из библиотеки, а
|
||||||
|
jellybit по-прежнему числит загрузку `done` — состояние врёт.
|
||||||
|
|
||||||
|
Цель этого change — **path 1**: научить jellybit корректно **распознавать**
|
||||||
|
ручное удаление и **отражать** его в состоянии, **не предпринимая
|
||||||
|
автоматических действий**. Удаление средствами самого jellybit («единое
|
||||||
|
окно», path 2) — отдельная будущая работа.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Фоновая сверка с реальностью.** `worker` расширяет периодический поллинг:
|
||||||
|
помимо `downloading` сверяет терминальные/desync-задачи с фактом на ФС —
|
||||||
|
присутствие раздачи в qBittorrent (источник) и существование разложенных
|
||||||
|
хардлинков `file_link.dst_path` (цель).
|
||||||
|
- **Новые состояния FSM** для рассинхрона (двумерная матрица «источник × цель»):
|
||||||
|
- `target_missing` — источник на месте, цель удалена: доступен
|
||||||
|
пользовательский флоу повторной привязки (relink); авто-действий нет.
|
||||||
|
- `orphaned` — источник пропал, цель на месте: «осиротевшая» раздача,
|
||||||
|
библиотечный хардлинк — единственная копия.
|
||||||
|
- `deleted` — пропали и источник, и цель: терминально, действий больше нет.
|
||||||
|
- Реальность «лечится» сама: если источник/цель снова появились, сверка
|
||||||
|
возвращает задачу в согласованное состояние.
|
||||||
|
- **Дебаунс пропажи источника.** Раздача считается удалённой только после
|
||||||
|
`N` подряд тиков без неё (qBittorrent мог рестартовать / API мигнул);
|
||||||
|
любое появление сбрасывает счётчик. Порог — в конфиге.
|
||||||
|
- **Защита `Undo` от потери данных.** `Undo`/`layout.Undo` отказывается
|
||||||
|
снимать хардлинк, если он **последняя копия** (`nlink == 1`) или исходный
|
||||||
|
путь не существует — откат снимает лишний хардлинк, а не единственный
|
||||||
|
файл; причина отказа сообщается явно.
|
||||||
|
- **Уведомление** автору загрузки при переходе в `orphaned`/`target_missing`
|
||||||
|
(через существующий `notifier`), чтобы рассинхрон не оставался незаметным.
|
||||||
|
|
||||||
|
Не входит в объём (Non-goals):
|
||||||
|
|
||||||
|
- Удаление раздач/файлов средствами самого jellybit (path 2, «единое окно»).
|
||||||
|
- Автоматический повторный прогон распознавания/раскладки при рассинхроне —
|
||||||
|
только пометка состояния; relink инициирует человек.
|
||||||
|
- Сверка содержимого источника (manual delete файлов **внутри** живой
|
||||||
|
раздачи → это `error`/`missingFiles` qBittorrent, отдельная тема).
|
||||||
|
- Ретеншн/авточистка терминальных задач (отдельная задача в TODO).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `state-reconciliation`: периодическая сверка записанного состояния
|
||||||
|
загрузки с фактом на ФС (раздача в qBittorrent, разложенные хардлинки),
|
||||||
|
состояния рассинхрона (`target_missing`/`orphaned`/`deleted`), их переходы
|
||||||
|
и дебаунс; а также инвариант безопасного `Undo` (не снимать последнюю
|
||||||
|
копию).
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
<!-- Граф состояний и Undo живут в docs/specs/workflow.md и
|
||||||
|
docs/specs/jellyfin-layout.md и ещё не перенесены в OpenSpec; меняем их
|
||||||
|
напрямую как живые спеки (см. Impact). Capability-дельт для них нет. -->
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Спеки:** новая `openspec/specs/state-reconciliation/`; правки живых
|
||||||
|
`docs/specs/workflow.md` (граф состояний: +`target_missing`/`orphaned`/
|
||||||
|
`deleted`, переходы) и `docs/specs/jellyfin-layout.md` (инвариант
|
||||||
|
безопасного `Undo`).
|
||||||
|
- **Код:** `internal/worker` (расширение `Poll`/`reconcile` на терминальные
|
||||||
|
задачи, дебаунс, новые переходы, уведомления), `internal/store` (новые
|
||||||
|
значения `state`, столбец счётчика промахов, миграция, запросы выборки
|
||||||
|
desync-задач), `internal/layout` (`Undo` с проверкой `nlink`/наличия
|
||||||
|
источника), `internal/qbt` (присутствие infohash — уже листаем все
|
||||||
|
торренты), `internal/httpapi` + web-UI (отображение новых состояний,
|
||||||
|
блокировка `Undo` для `orphaned`), `internal/config` (`[worker]` порог
|
||||||
|
дебаунса).
|
||||||
|
- **Конфиг:** новый ключ в `[worker]` (порог пропусков источника).
|
||||||
|
- **Совместимость:** новые значения `state` — аддитивно; миграция goose для
|
||||||
|
столбца счётчика.
|
||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Периодическая сверка состояния с реальностью
|
||||||
|
|
||||||
|
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
|
||||||
|
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
|
||||||
|
выводить состояние задачи из двух независимых признаков: присутствия
|
||||||
|
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и
|
||||||
|
присутствия **цели** (все `file_link` со `status = linked` существуют на ФС).
|
||||||
|
|
||||||
|
Сверке SHALL подвергаться только состояния `done`, `target_missing`,
|
||||||
|
`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/
|
||||||
|
`deferred`/`linking`) и пользовательски-терминальные (`reverted`/`cancelled`/
|
||||||
|
`failed`/`stuck`) состояния сверка трогать SHALL NOT.
|
||||||
|
|
||||||
|
Состояние SHALL переписываться только при его изменении (без записи и логов,
|
||||||
|
когда выведенное состояние совпадает с текущим).
|
||||||
|
|
||||||
|
#### Scenario: Источник и цель на месте — состояние не меняется
|
||||||
|
|
||||||
|
- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её
|
||||||
|
разложенные хардлинки существуют
|
||||||
|
- **THEN** задача остаётся в `done`
|
||||||
|
- **AND** запись состояния и лог перехода не выполняются
|
||||||
|
|
||||||
|
#### Scenario: Частичная пропажа цели считается отсутствием
|
||||||
|
|
||||||
|
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
|
||||||
|
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
|
||||||
|
|
||||||
|
### Requirement: Принудительная проверка источника/цели перед действием
|
||||||
|
|
||||||
|
Команда workflow, требующая наличия источника или цели, SHALL синхронно
|
||||||
|
проверять их присутствие непосредственно перед выполнением действия и SHALL
|
||||||
|
NOT полагаться только на фоновую сверку `worker` (она отстаёт на интервал
|
||||||
|
поллинга и дебаунс). Проверка перед действием выполняется как **единичная
|
||||||
|
немедленная проба без дебаунса**: дебаунс применяется только к фоновому
|
||||||
|
авто-маркированию.
|
||||||
|
|
||||||
|
Под это требование подпадают как минимум: повторная привязка/распознавание
|
||||||
|
(`relink`, «Распознать заново», «Уточнить») и раскладка (`Apply`) — требуют
|
||||||
|
**источника**; `Undo` — требует **источника** (чтобы не снять последнюю
|
||||||
|
копию).
|
||||||
|
|
||||||
|
Если предусловие не выполнено, система SHALL NOT выполнять действие, SHALL
|
||||||
|
привести состояние задачи в соответствие с реальностью (вывести состояние из
|
||||||
|
матрицы «источник × цель», как при сверке) и SHALL сообщить причину
|
||||||
|
пользователю.
|
||||||
|
|
||||||
|
#### Scenario: Relink проверяет источник перед запуском
|
||||||
|
|
||||||
|
- **WHEN** пользователь даёт команду, требующую источника (например
|
||||||
|
«Привязать заново»)
|
||||||
|
- **THEN** система синхронно проверяет наличие раздачи в qBittorrent перед
|
||||||
|
запуском распознавания
|
||||||
|
- **AND** если источника нет — распознавание не запускается, задача
|
||||||
|
приводится к `orphaned` либо `deleted` (по наличию цели), причина
|
||||||
|
сообщается
|
||||||
|
|
||||||
|
#### Scenario: Проверка не ждёт фоновую сверку
|
||||||
|
|
||||||
|
- **WHEN** источник уже удалён, а фоновая сверка ещё не отметила это (в БД
|
||||||
|
состояние, например, `done`)
|
||||||
|
- **THEN** команда, требующая источника, немедленно обнаруживает его
|
||||||
|
отсутствие собственной проверкой и отказывает, не дожидаясь `worker`
|
||||||
|
|
||||||
|
### Requirement: Состояние target_missing и доступность повторной привязки
|
||||||
|
|
||||||
|
Когда у задачи источник присутствует, а цель отсутствует, система SHALL
|
||||||
|
переводить её в состояние `target_missing` и SHALL NOT предпринимать
|
||||||
|
автоматических действий (не запускать повторное распознавание/раскладку
|
||||||
|
самостоятельно).
|
||||||
|
|
||||||
|
Из `target_missing` система SHALL предоставлять пользовательскую команду
|
||||||
|
повторной привязки (relink) с переходом `target_missing → recognizing`, как
|
||||||
|
из `reverted`/`cancelled`; повторная привязка идёт через `review` с ручным
|
||||||
|
подтверждением.
|
||||||
|
|
||||||
|
#### Scenario: Цель удалена, источник на месте
|
||||||
|
|
||||||
|
- **WHEN** сверка обнаруживает, что разложенных хардлинков задачи `done`
|
||||||
|
больше нет, но раздача в qBittorrent присутствует
|
||||||
|
- **THEN** задача переходит в `target_missing`
|
||||||
|
- **AND** система не запускает распознавание или раскладку автоматически
|
||||||
|
|
||||||
|
#### Scenario: Пользователь инициирует повторную привязку
|
||||||
|
|
||||||
|
- **WHEN** для задачи в `target_missing` пользователь даёт команду «Привязать
|
||||||
|
заново»
|
||||||
|
- **THEN** задача переходит в `recognizing` и далее проходит через `review`
|
||||||
|
с ручным подтверждением
|
||||||
|
|
||||||
|
### Requirement: Состояние orphaned при пропаже источника
|
||||||
|
|
||||||
|
Система SHALL переводить задачу в состояние `orphaned`, когда источник
|
||||||
|
отсутствует (с учётом дебаунса), а цель присутствует, отражая, что
|
||||||
|
библиотечный хардлинк остался единственной копией данных.
|
||||||
|
|
||||||
|
#### Scenario: Источник удалён, цель на месте
|
||||||
|
|
||||||
|
- **WHEN** сверка устойчиво (после дебаунса) не находит раздачу задачи в
|
||||||
|
qBittorrent, а её разложенные хардлинки существуют
|
||||||
|
- **THEN** задача переходит в `orphaned`
|
||||||
|
|
||||||
|
### Requirement: Состояние deleted при пропаже источника и цели
|
||||||
|
|
||||||
|
Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL
|
||||||
|
переводить задачу в состояние `deleted`. В `deleted` действий над задачей
|
||||||
|
больше нет.
|
||||||
|
|
||||||
|
#### Scenario: Источник и цель удалены
|
||||||
|
|
||||||
|
- **WHEN** сверка устойчиво не находит раздачу в qBittorrent и разложенных
|
||||||
|
хардлинков задачи на ФС больше нет
|
||||||
|
- **THEN** задача переходит в `deleted`
|
||||||
|
|
||||||
|
### Requirement: Дебаунс пропажи источника
|
||||||
|
|
||||||
|
Система SHALL дебаунсить только **отсутствие источника**, чтобы временная
|
||||||
|
недоступность qBittorrent (рестарт демона, сбой API) не вызывала ложных
|
||||||
|
пометок: источник считается удалённым лишь после `N` подряд тиков сверки без
|
||||||
|
него, где `N = [worker].source_missing_threshold`. Любое обнаружение раздачи
|
||||||
|
SHALL сбрасывать счётчик пропусков.
|
||||||
|
|
||||||
|
Отсутствие цели дебаунсу подвергаться SHALL NOT (локальная проверка ФС
|
||||||
|
надёжна).
|
||||||
|
|
||||||
|
#### Scenario: Кратковременная пропажа источника не помечается
|
||||||
|
|
||||||
|
- **WHEN** раздача отсутствует в qBittorrent меньше `source_missing_threshold`
|
||||||
|
тиков подряд
|
||||||
|
- **THEN** источник трактуется как присутствующий и состояние задачи не
|
||||||
|
меняется
|
||||||
|
|
||||||
|
#### Scenario: Возврат источника сбрасывает счётчик
|
||||||
|
|
||||||
|
- **WHEN** раздача снова обнаружена в qBittorrent
|
||||||
|
- **THEN** счётчик пропусков источника сбрасывается в ноль
|
||||||
|
|
||||||
|
### Requirement: Самовосстановление состояния при возврате реальности
|
||||||
|
|
||||||
|
Система SHALL возвращать задачу в согласованное состояние, когда реальность
|
||||||
|
восстановилась (состояние выводится из текущей матрицы «источник × цель»):
|
||||||
|
при возврате источника и/или цели задача SHALL переходить из
|
||||||
|
`orphaned`/`target_missing`/`deleted` обратно (в т.ч. в `done`, когда
|
||||||
|
присутствуют оба).
|
||||||
|
|
||||||
|
#### Scenario: Источник вернулся
|
||||||
|
|
||||||
|
- **WHEN** для задачи в `orphaned` раздача снова появилась в qBittorrent, а
|
||||||
|
цель по-прежнему на месте
|
||||||
|
- **THEN** задача возвращается в `done`
|
||||||
|
|
||||||
|
### Requirement: Безопасный Undo не снимает последнюю копию
|
||||||
|
|
||||||
|
`Undo` (снятие созданных хардлинков) SHALL отказываться удалять целевую
|
||||||
|
ссылку, если она является последней копией данных: целевой файл существует и
|
||||||
|
его счётчик ссылок `nlink <= 1`, либо исходный файл (`src_path`) не
|
||||||
|
существует. В этом случае система SHALL NOT выполнять `unlink` такого файла и
|
||||||
|
SHALL явно сообщать причину отказа; задача в `reverted` при отказе переходить
|
||||||
|
SHALL NOT.
|
||||||
|
|
||||||
|
Отсутствующую целевую ссылку (файла уже нет) `Undo` SHALL пропускать как
|
||||||
|
успешно снятую (идемпотентность). Команда `Undo` для задачи в `orphaned`
|
||||||
|
SHALL отклоняться сразу с пояснением, что источник удалён.
|
||||||
|
|
||||||
|
#### Scenario: Отказ снять единственную копию
|
||||||
|
|
||||||
|
- **WHEN** при `Undo` целевой хардлинк существует, но его `nlink <= 1` (или
|
||||||
|
исходный файл отсутствует)
|
||||||
|
- **THEN** система не удаляет файл и сообщает, что это последняя копия
|
||||||
|
- **AND** задача остаётся в текущем состоянии (не `reverted`)
|
||||||
|
|
||||||
|
#### Scenario: Undo снимает лишний хардлинк при живом источнике
|
||||||
|
|
||||||
|
- **WHEN** при `Undo` целевой хардлинк существует, исходный файл на месте и
|
||||||
|
`nlink > 1`
|
||||||
|
- **THEN** система снимает целевой хардлинк, оставляя исходный файл нетронутым
|
||||||
|
|
||||||
|
### Requirement: Уведомление о рассинхроне
|
||||||
|
|
||||||
|
При переходе задачи в `orphaned` или `target_missing` система SHALL
|
||||||
|
уведомлять автора загрузки через настроенный механизм уведомлений
|
||||||
|
(`notifier`), чтобы рассинхрон не оставался незамеченным.
|
||||||
|
|
||||||
|
#### Scenario: Уведомление при потере источника
|
||||||
|
|
||||||
|
- **WHEN** задача переходит в `orphaned`
|
||||||
|
- **THEN** система отправляет автору загрузки уведомление о потере источника
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
## 1. Хранилище и состояния
|
||||||
|
|
||||||
|
- [x] 1.1 Добавить значения состояний `target_missing`, `orphaned`, `deleted`
|
||||||
|
в `internal/store` (константы `State*`) и в перечень допустимых состояний.
|
||||||
|
- [x] 1.2 Миграция goose `0003_*`: `ALTER TABLE download ADD COLUMN
|
||||||
|
source_miss_count INTEGER NOT NULL DEFAULT 0`; обновить модель `Download`.
|
||||||
|
- [x] 1.3 Запросы в `store`: выборка desync-кандидатов
|
||||||
|
(`done`/`target_missing`/`orphaned`/`deleted`), чтение/сброс/инкремент
|
||||||
|
`source_miss_count`, чтение `file_link` (`status = linked`) по задаче.
|
||||||
|
|
||||||
|
## 2. Конфигурация
|
||||||
|
|
||||||
|
- [x] 2.1 Добавить `[worker].source_missing_threshold` (int, дефолт `3`) в
|
||||||
|
`internal/config` с валидацией (`>= 1`); пробросить в `worker.Config`.
|
||||||
|
- [x] 2.2 Отразить ключ в примере конфига и `docs/conventions/config.md`/
|
||||||
|
README, если там перечислены ключи `[worker]`.
|
||||||
|
|
||||||
|
## 3. Сверка с реальностью (worker)
|
||||||
|
|
||||||
|
- [x] 3.1 Выделить общий помощник `probe(download) → (sourcePresent,
|
||||||
|
targetPresent)` (источник: infohash в qBit; цель: `Lstat` всех
|
||||||
|
`file_link.dst_path` со `status = linked`, частичная пропажа = цель
|
||||||
|
отсутствует) и `deriveState(src, tgt) → State` — единая точка правды для
|
||||||
|
фона и preflight.
|
||||||
|
- [x] 3.2 В `Poll` для desync-кандидатов вызвать `probe` и реализовать
|
||||||
|
дебаунс источника: инкремент `source_miss_count` при отсутствии, сброс при
|
||||||
|
обнаружении; «источник удалён» только при `source_miss_count >= threshold`.
|
||||||
|
- [x] 3.3 Применить `deriveState` и переходить только при изменении:
|
||||||
|
`done`/`target_missing`/`orphaned`/`deleted` + healing обратно в `done`.
|
||||||
|
Переиспользовать `transition`.
|
||||||
|
- [x] 3.4 Не трогать фоновой сверкой активные и пользовательски-терминальные
|
||||||
|
состояния (`reverted`/`cancelled`/`failed`/`stuck` и активные).
|
||||||
|
|
||||||
|
## 3a. Синхронный preflight перед действием
|
||||||
|
|
||||||
|
- [x] 3a.1 Перед командами, требующими источника/цели (relink, «Распознать
|
||||||
|
заново», «Уточнить», `Apply`, `Undo`), вызывать `probe` **без дебаунса**
|
||||||
|
(единичная немедленная проба), не доверяя `state` в БД.
|
||||||
|
- [x] 3a.2 При неуспехе предусловия: действие не выполнять, прогнать
|
||||||
|
`deriveState` (привести `state` к реальности, напр. `done → orphaned`),
|
||||||
|
вернуть пользователю причину; при недоступности qBittorrent — отказ
|
||||||
|
«источник недоступен».
|
||||||
|
|
||||||
|
## 4. Безопасный Undo (layout + worker)
|
||||||
|
|
||||||
|
- [x] 4.1 `internal/layout` `Undo`: для каждой ссылки `Lstat(dst)` (нет —
|
||||||
|
пропустить идемпотентно), иначе проверить `nlink <= 1` и наличие
|
||||||
|
`src_path`; при «последней копии» — отказ с типизированной ошибкой, без
|
||||||
|
`unlink`.
|
||||||
|
- [x] 4.2 `worker.Undo`: отклонять команду для задачи в `orphaned` сразу с
|
||||||
|
понятным сообщением; при отказе `layout.Undo` — не переводить в `reverted`,
|
||||||
|
пробросить причину пользователю.
|
||||||
|
- [x] 4.3 Разрешить переход `target_missing → recognizing` в команде
|
||||||
|
«Привязать заново» (наряду с `reverted`/`cancelled`).
|
||||||
|
|
||||||
|
## 5. Уведомления
|
||||||
|
|
||||||
|
- [x] 5.1 Добавить события `EventOrphaned`/`EventTargetMissing` и слать
|
||||||
|
уведомление автору в `transition` при входе в эти состояния (как для
|
||||||
|
`review`/`done`), неблокирующе и вне `w.mu`.
|
||||||
|
|
||||||
|
## 6. Транспорты (httpapi + web-UI)
|
||||||
|
|
||||||
|
- [x] 6.1 Отобразить новые состояния в списке/карточке загрузки (метки,
|
||||||
|
пояснения «разложено, но файлов нет» / «источник удалён — последняя копия»).
|
||||||
|
- [x] 6.2 Скрыть/заблокировать `Undo` для `orphaned`; показать команду
|
||||||
|
«Привязать заново» для `target_missing`.
|
||||||
|
|
||||||
|
## 7. Тесты
|
||||||
|
|
||||||
|
- [x] 7.1 Таблица переходов сверки: все четыре ячейки матрицы + healing,
|
||||||
|
частичная пропажа цели → `target_missing`.
|
||||||
|
- [x] 7.2 Дебаунс: пропажа < порога не помечает; >= порога помечает; возврат
|
||||||
|
сбрасывает счётчик.
|
||||||
|
- [x] 7.3 `layout.Undo`: отказ при `nlink <= 1`/отсутствии `src_path`;
|
||||||
|
снятие лишнего хардлинка при живом источнике; пропуск отсутствующей цели.
|
||||||
|
- [x] 7.4 `worker.Undo` отклоняется для `orphaned`; relink из
|
||||||
|
`target_missing` ведёт в `recognizing`.
|
||||||
|
- [x] 7.5 Preflight: команда с устаревшим `state = done`, но удалённым
|
||||||
|
источником немедленно отказывает и приводит состояние к `orphaned`/
|
||||||
|
`deleted` (не дожидаясь фоновой сверки).
|
||||||
|
|
||||||
|
## 8. Документация и спеки
|
||||||
|
|
||||||
|
- [x] 8.1 Обновить `docs/specs/workflow.md`: граф состояний (+`target_missing`/
|
||||||
|
`orphaned`/`deleted`, переходы, healing) и описания.
|
||||||
|
- [x] 8.2 Обновить `docs/specs/jellyfin-layout.md`: инвариант безопасного
|
||||||
|
`Undo` (не снимать последнюю копию).
|
||||||
|
- [x] 8.2a Обновить ER-схему `docs/specs/database.md`: столбец
|
||||||
|
`download.source_miss_count` + отметка миграции `0003` (конвенция:
|
||||||
|
схема едет вместе с миграцией).
|
||||||
|
- [x] 8.3 Снять пункт «Рассинхрон состояния с реальностью» (часть про
|
||||||
|
detection/marking и undo-guard) из `docs/todo.md` или сузить до path 2.
|
||||||
|
- [x] 8.4 `openspec validate --strict`; ревью кода; затем `opsx:archive`
|
||||||
|
(влить дельту `state-reconciliation` в `openspec/specs/`).
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# recognition Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Распознавание: сопоставление загрузки с конкретным фильмом/сериалом во
|
||||||
|
включённых базах метаданных. Capability описывает контракт LLM на названия,
|
||||||
|
порядок и нормализацию сверки по нескольким названиям, локаль запроса к TMDB
|
||||||
|
и сбор кандидатов для ручного выбора в review.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Сверка с базой по нескольким названиям
|
||||||
|
|
||||||
|
При сверке плана с включёнными базами метаданных система SHALL искать по
|
||||||
|
нескольким названиям в порядке убывания силы ключа: сначала по
|
||||||
|
`original_title`, затем по локализованному `title`, затем по `provider_hint`.
|
||||||
|
Поиск SHALL останавливаться, как только очередной запрос дал единичный
|
||||||
|
сильный матч (ровно один кандидат с совпадением названия и года). Запрос с
|
||||||
|
названием, нормализованно совпадающим с уже выполненным, система SHALL
|
||||||
|
пропускать, чтобы не обращаться к базе повторно с тем же ключом.
|
||||||
|
|
||||||
|
Кандидаты для ручного выбора в review система SHALL собирать из всех
|
||||||
|
выполненных заходов с дедупликацией по `provider:id` и общим потолком.
|
||||||
|
|
||||||
|
#### Scenario: Иностранный фильм находится по оригинальному названию
|
||||||
|
|
||||||
|
- **GIVEN** план с `title` «Тёмный рыцарь», `original_title` «The Dark Knight», год 2008
|
||||||
|
- **WHEN** выполняется сверка с базой
|
||||||
|
- **THEN** первый запрос идёт по «The Dark Knight»
|
||||||
|
- **AND** при единичном сильном матче дальнейшие запросы (по `title`, `provider_hint`) не выполняются
|
||||||
|
|
||||||
|
#### Scenario: Фолбэк на локализованное название
|
||||||
|
|
||||||
|
- **GIVEN** план, для которого запрос по `original_title` не дал единичного сильного матча
|
||||||
|
- **WHEN** продолжается сверка
|
||||||
|
- **THEN** выполняется запрос по локализованному `title`
|
||||||
|
- **AND** при отсутствии матча и там — запрос по `provider_hint`
|
||||||
|
|
||||||
|
#### Scenario: Дублирующий запрос пропускается
|
||||||
|
|
||||||
|
- **GIVEN** план, у которого `original_title` нормализованно совпадает с `title`
|
||||||
|
- **WHEN** выполняется сверка
|
||||||
|
- **THEN** база запрашивается этим названием один раз, повторный заход по `title` не делается
|
||||||
|
|
||||||
|
### Requirement: Контракт LLM на оригинальное и локализованное названия
|
||||||
|
|
||||||
|
Промпт распознавания SHALL требовать от модели всегда заполнять и `title`, и
|
||||||
|
`original_title`. Если отдельного оригинального названия нет или контент
|
||||||
|
российского происхождения, модель SHALL дублировать `title` в
|
||||||
|
`original_title`. При неуверенности в оригинальном названии модель SHALL
|
||||||
|
дублировать `title`, а не выдумывать название (защита от ложного авто-матча).
|
||||||
|
|
||||||
|
Разбор ответа SHALL оставаться устойчивым к пустому `original_title`: пустое
|
||||||
|
значение не отбраковывается и не вызывает correction-ретрай; сверка
|
||||||
|
gracefully использует доступные названия.
|
||||||
|
|
||||||
|
#### Scenario: Российский фильм — дублирование
|
||||||
|
|
||||||
|
- **GIVEN** раздача российского фильма без отдельного оригинального названия
|
||||||
|
- **WHEN** модель возвращает план
|
||||||
|
- **THEN** `title` и `original_title` заполнены одинаковым каноническим названием
|
||||||
|
|
||||||
|
#### Scenario: Пустой original_title не ломает разбор
|
||||||
|
|
||||||
|
- **GIVEN** ответ модели с пустым `original_title`
|
||||||
|
- **WHEN** план разбирается
|
||||||
|
- **THEN** разбор успешен без correction-ретрая
|
||||||
|
- **AND** сверка использует `title` (и `provider_hint`)
|
||||||
|
|
||||||
|
### Requirement: Локаль запроса к TMDB
|
||||||
|
|
||||||
|
Запрос поиска к TMDB SHALL передавать параметр `language`, по умолчанию
|
||||||
|
`ru-RU`, со значением, настраиваемым конфигом `[metadata.tmdb].language`.
|
||||||
|
Это влияет только на локализованное поле `Title`/`Name`; поле
|
||||||
|
`original_title`/`original_name` остаётся на языке оригинала, поэтому
|
||||||
|
оригинальная сторона сравнения не затрагивается.
|
||||||
|
|
||||||
|
#### Scenario: Локализованный заголовок приходит по-русски
|
||||||
|
|
||||||
|
- **GIVEN** TMDB включён, `language` не задан в конфиге
|
||||||
|
- **WHEN** выполняется поиск фильма с русской локализацией
|
||||||
|
- **THEN** запрос содержит `language=ru-RU`
|
||||||
|
- **AND** в кандидате `Title` приходит на русском, а `OriginalTitle` — на языке оригинала
|
||||||
|
|
||||||
|
### Requirement: Нормализация названий при сравнении
|
||||||
|
|
||||||
|
Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`,
|
||||||
|
чтобы написания, различающиеся только `ё`/`е`, считались одним названием.
|
||||||
|
|
||||||
|
#### Scenario: «Тёмный» и «Темный» совпадают
|
||||||
|
|
||||||
|
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
|
||||||
|
- **WHEN** сравниваются нормализованные названия
|
||||||
|
- **THEN** они считаются совпадающими
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# state-reconciliation Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Сверка записанного состояния загрузки с фактом на файловой системе и в
|
||||||
|
qBittorrent. Capability описывает периодическую и принудительную проверку
|
||||||
|
присутствия **источника** (раздача в qBittorrent) и **цели** (разложенные
|
||||||
|
хардлинки), вывод состояний рассинхрона (`target_missing`/`orphaned`/
|
||||||
|
`deleted`) из матрицы «источник × цель», их переходы и самовосстановление,
|
||||||
|
дебаунс пропажи источника, инвариант безопасного `Undo` (не снимать
|
||||||
|
последнюю копию) и уведомления о рассинхроне.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Периодическая сверка состояния с реальностью
|
||||||
|
|
||||||
|
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
|
||||||
|
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
|
||||||
|
выводить состояние задачи из двух независимых признаков: присутствия
|
||||||
|
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и
|
||||||
|
присутствия **цели** (все `file_link` со `status = linked` существуют на ФС).
|
||||||
|
|
||||||
|
Сверке SHALL подвергаться только состояния `done`, `target_missing`,
|
||||||
|
`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/
|
||||||
|
`deferred`/`linking`) и пользовательски-терминальные (`reverted`/`cancelled`/
|
||||||
|
`failed`/`stuck`) состояния сверка трогать SHALL NOT.
|
||||||
|
|
||||||
|
Состояние SHALL переписываться только при его изменении (без записи и логов,
|
||||||
|
когда выведенное состояние совпадает с текущим).
|
||||||
|
|
||||||
|
#### Scenario: Источник и цель на месте — состояние не меняется
|
||||||
|
|
||||||
|
- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её
|
||||||
|
разложенные хардлинки существуют
|
||||||
|
- **THEN** задача остаётся в `done`
|
||||||
|
- **AND** запись состояния и лог перехода не выполняются
|
||||||
|
|
||||||
|
#### Scenario: Частичная пропажа цели считается отсутствием
|
||||||
|
|
||||||
|
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
|
||||||
|
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
|
||||||
|
|
||||||
|
### Requirement: Принудительная проверка источника/цели перед действием
|
||||||
|
|
||||||
|
Команда workflow, требующая наличия источника или цели, SHALL синхронно
|
||||||
|
проверять их присутствие непосредственно перед выполнением действия и SHALL
|
||||||
|
NOT полагаться только на фоновую сверку `worker` (она отстаёт на интервал
|
||||||
|
поллинга и дебаунс). Проверка перед действием выполняется как **единичная
|
||||||
|
немедленная проба без дебаунса**: дебаунс применяется только к фоновому
|
||||||
|
авто-маркированию.
|
||||||
|
|
||||||
|
Под это требование подпадают как минимум: повторная привязка/распознавание
|
||||||
|
(`relink`, «Распознать заново», «Уточнить») и раскладка (`Apply`) — требуют
|
||||||
|
**источника**; `Undo` — требует **источника** (чтобы не снять последнюю
|
||||||
|
копию).
|
||||||
|
|
||||||
|
Если предусловие не выполнено, система SHALL NOT выполнять действие, SHALL
|
||||||
|
привести состояние задачи в соответствие с реальностью (вывести состояние из
|
||||||
|
матрицы «источник × цель», как при сверке) и SHALL сообщить причину
|
||||||
|
пользователю.
|
||||||
|
|
||||||
|
#### Scenario: Relink проверяет источник перед запуском
|
||||||
|
|
||||||
|
- **WHEN** пользователь даёт команду, требующую источника (например
|
||||||
|
«Привязать заново»)
|
||||||
|
- **THEN** система синхронно проверяет наличие раздачи в qBittorrent перед
|
||||||
|
запуском распознавания
|
||||||
|
- **AND** если источника нет — распознавание не запускается, задача
|
||||||
|
приводится к `orphaned` либо `deleted` (по наличию цели), причина
|
||||||
|
сообщается
|
||||||
|
|
||||||
|
#### Scenario: Проверка не ждёт фоновую сверку
|
||||||
|
|
||||||
|
- **WHEN** источник уже удалён, а фоновая сверка ещё не отметила это (в БД
|
||||||
|
состояние, например, `done`)
|
||||||
|
- **THEN** команда, требующая источника, немедленно обнаруживает его
|
||||||
|
отсутствие собственной проверкой и отказывает, не дожидаясь `worker`
|
||||||
|
|
||||||
|
### Requirement: Состояние target_missing и доступность повторной привязки
|
||||||
|
|
||||||
|
Когда у задачи источник присутствует, а цель отсутствует, система SHALL
|
||||||
|
переводить её в состояние `target_missing` и SHALL NOT предпринимать
|
||||||
|
автоматических действий (не запускать повторное распознавание/раскладку
|
||||||
|
самостоятельно).
|
||||||
|
|
||||||
|
Из `target_missing` система SHALL предоставлять пользовательскую команду
|
||||||
|
повторной привязки (relink) с переходом `target_missing → recognizing`, как
|
||||||
|
из `reverted`/`cancelled`; повторная привязка идёт через `review` с ручным
|
||||||
|
подтверждением.
|
||||||
|
|
||||||
|
#### Scenario: Цель удалена, источник на месте
|
||||||
|
|
||||||
|
- **WHEN** сверка обнаруживает, что разложенных хардлинков задачи `done`
|
||||||
|
больше нет, но раздача в qBittorrent присутствует
|
||||||
|
- **THEN** задача переходит в `target_missing`
|
||||||
|
- **AND** система не запускает распознавание или раскладку автоматически
|
||||||
|
|
||||||
|
#### Scenario: Пользователь инициирует повторную привязку
|
||||||
|
|
||||||
|
- **WHEN** для задачи в `target_missing` пользователь даёт команду «Привязать
|
||||||
|
заново»
|
||||||
|
- **THEN** задача переходит в `recognizing` и далее проходит через `review`
|
||||||
|
с ручным подтверждением
|
||||||
|
|
||||||
|
### Requirement: Состояние orphaned при пропаже источника
|
||||||
|
|
||||||
|
Система SHALL переводить задачу в состояние `orphaned`, когда источник
|
||||||
|
отсутствует (с учётом дебаунса), а цель присутствует, отражая, что
|
||||||
|
библиотечный хардлинк остался единственной копией данных.
|
||||||
|
|
||||||
|
#### Scenario: Источник удалён, цель на месте
|
||||||
|
|
||||||
|
- **WHEN** сверка устойчиво (после дебаунса) не находит раздачу задачи в
|
||||||
|
qBittorrent, а её разложенные хардлинки существуют
|
||||||
|
- **THEN** задача переходит в `orphaned`
|
||||||
|
|
||||||
|
### Requirement: Состояние deleted при пропаже источника и цели
|
||||||
|
|
||||||
|
Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL
|
||||||
|
переводить задачу в состояние `deleted`. В `deleted` действий над задачей
|
||||||
|
больше нет.
|
||||||
|
|
||||||
|
#### Scenario: Источник и цель удалены
|
||||||
|
|
||||||
|
- **WHEN** сверка устойчиво не находит раздачу в qBittorrent и разложенных
|
||||||
|
хардлинков задачи на ФС больше нет
|
||||||
|
- **THEN** задача переходит в `deleted`
|
||||||
|
|
||||||
|
### Requirement: Дебаунс пропажи источника
|
||||||
|
|
||||||
|
Система SHALL дебаунсить только **отсутствие источника**, чтобы временная
|
||||||
|
недоступность qBittorrent (рестарт демона, сбой API) не вызывала ложных
|
||||||
|
пометок: источник считается удалённым лишь после `N` подряд тиков сверки без
|
||||||
|
него, где `N = [worker].source_missing_threshold`. Любое обнаружение раздачи
|
||||||
|
SHALL сбрасывать счётчик пропусков.
|
||||||
|
|
||||||
|
Отсутствие цели дебаунсу подвергаться SHALL NOT (локальная проверка ФС
|
||||||
|
надёжна).
|
||||||
|
|
||||||
|
#### Scenario: Кратковременная пропажа источника не помечается
|
||||||
|
|
||||||
|
- **WHEN** раздача отсутствует в qBittorrent меньше `source_missing_threshold`
|
||||||
|
тиков подряд
|
||||||
|
- **THEN** источник трактуется как присутствующий и состояние задачи не
|
||||||
|
меняется
|
||||||
|
|
||||||
|
#### Scenario: Возврат источника сбрасывает счётчик
|
||||||
|
|
||||||
|
- **WHEN** раздача снова обнаружена в qBittorrent
|
||||||
|
- **THEN** счётчик пропусков источника сбрасывается в ноль
|
||||||
|
|
||||||
|
### Requirement: Самовосстановление состояния при возврате реальности
|
||||||
|
|
||||||
|
Система SHALL возвращать задачу в согласованное состояние, когда реальность
|
||||||
|
восстановилась (состояние выводится из текущей матрицы «источник × цель»):
|
||||||
|
при возврате источника и/или цели задача SHALL переходить из
|
||||||
|
`orphaned`/`target_missing`/`deleted` обратно (в т.ч. в `done`, когда
|
||||||
|
присутствуют оба).
|
||||||
|
|
||||||
|
#### Scenario: Источник вернулся
|
||||||
|
|
||||||
|
- **WHEN** для задачи в `orphaned` раздача снова появилась в qBittorrent, а
|
||||||
|
цель по-прежнему на месте
|
||||||
|
- **THEN** задача возвращается в `done`
|
||||||
|
|
||||||
|
### Requirement: Безопасный Undo не снимает последнюю копию
|
||||||
|
|
||||||
|
`Undo` (снятие созданных хардлинков) SHALL отказываться удалять целевую
|
||||||
|
ссылку, если она является последней копией данных: целевой файл существует и
|
||||||
|
его счётчик ссылок `nlink <= 1`, либо исходный файл (`src_path`) не
|
||||||
|
существует. В этом случае система SHALL NOT выполнять `unlink` такого файла и
|
||||||
|
SHALL явно сообщать причину отказа; задача в `reverted` при отказе переходить
|
||||||
|
SHALL NOT.
|
||||||
|
|
||||||
|
Отсутствующую целевую ссылку (файла уже нет) `Undo` SHALL пропускать как
|
||||||
|
успешно снятую (идемпотентность). Команда `Undo` для задачи в `orphaned`
|
||||||
|
SHALL отклоняться сразу с пояснением, что источник удалён.
|
||||||
|
|
||||||
|
#### Scenario: Отказ снять единственную копию
|
||||||
|
|
||||||
|
- **WHEN** при `Undo` целевой хардлинк существует, но его `nlink <= 1` (или
|
||||||
|
исходный файл отсутствует)
|
||||||
|
- **THEN** система не удаляет файл и сообщает, что это последняя копия
|
||||||
|
- **AND** задача остаётся в текущем состоянии (не `reverted`)
|
||||||
|
|
||||||
|
#### Scenario: Undo снимает лишний хардлинк при живом источнике
|
||||||
|
|
||||||
|
- **WHEN** при `Undo` целевой хардлинк существует, исходный файл на месте и
|
||||||
|
`nlink > 1`
|
||||||
|
- **THEN** система снимает целевой хардлинк, оставляя исходный файл нетронутым
|
||||||
|
|
||||||
|
### Requirement: Уведомление о рассинхроне
|
||||||
|
|
||||||
|
При переходе задачи в `orphaned` или `target_missing` система SHALL
|
||||||
|
уведомлять автора загрузки через настроенный механизм уведомлений
|
||||||
|
(`notifier`), чтобы рассинхрон не оставался незамеченным.
|
||||||
|
|
||||||
|
#### Scenario: Уведомление при потере источника
|
||||||
|
|
||||||
|
- **WHEN** задача переходит в `orphaned`
|
||||||
|
- **THEN** система отправляет автору загрузки уведомление о потере источника
|
||||||
@@ -28,6 +28,9 @@
|
|||||||
.state-failed { background: #e74c3c55; }
|
.state-failed { background: #e74c3c55; }
|
||||||
.state-cancelled { background: #95a5a655; }
|
.state-cancelled { background: #95a5a655; }
|
||||||
.state-reverted { background: #95a5a655; }
|
.state-reverted { background: #95a5a655; }
|
||||||
|
.state-target_missing { background: #e67e2255; }
|
||||||
|
.state-orphaned { background: #e74c3c88; }
|
||||||
|
.state-deleted { background: #95a5a655; }
|
||||||
.actions { display: flex; gap: .4rem; flex-wrap: wrap; }
|
.actions { display: flex; gap: .4rem; flex-wrap: wrap; }
|
||||||
a.button { display: inline-block; padding: .35rem .6rem; border: 1px solid #8886; border-radius: .3rem; text-decoration: none; }
|
a.button { display: inline-block; padding: .35rem .6rem; border: 1px solid #8886; border-radius: .3rem; text-decoration: none; }
|
||||||
small { color: #8888; }
|
small { color: #8888; }
|
||||||
@@ -57,6 +60,7 @@
|
|||||||
<td>{{.Context}}</td>
|
<td>{{.Context}}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="state state-{{.State}}">{{.State}}</span>
|
<span class="state state-{{.State}}">{{.State}}</span>
|
||||||
|
{{if .Note}}<br><small>{{.Note}}</small>{{end}}
|
||||||
{{if .Error}}<br><small>{{.Error}}</small>{{end}}
|
{{if .Error}}<br><small>{{.Error}}</small>{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
Reference in New Issue
Block a user