Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c9421f4c1
|
||
|
|
e2ea1840c9
|
||
|
|
7d8a455e47
|
||
|
|
864c44aebd
|
||
|
|
f8fb4fabb3
|
||
|
|
5c3ef79496
|
||
|
|
3f1a928000
|
||
|
|
e68f96df9e
|
||
|
|
93a1ba8e7e
|
||
|
|
2bd2a97bfc
|
||
|
|
2a5a65f2d5
|
||
|
|
bfd469bd43
|
||
|
|
78c61605fd
|
||
|
|
4cc4de4269
|
||
|
|
9bab7dc402
|
||
|
|
8261d5b55d
|
||
|
|
4475fbd548
|
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: jellybit-review-code
|
||||
description: Ревьювер кода для jellybit (Go) — оптика архитектуры, инвариантов безопасности данных, конвенций (ошибки, логирование, конфиг, время/UTC, ULID, миграции, htmx), стиля и дублирования. Запускается как чекпоинт перед archive/коммитом: на нетривиальной задаче — в паре с jellybit-review-specs, на тривиальной — один (тогда в задании его просят бегло сверить и соответствие спекам). Работает только на чтение, код не меняет.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
color: yellow
|
||||
---
|
||||
|
||||
Ты — ревьювер кода проекта **jellybit** (Go, один статический бинарь
|
||||
`CGO_ENABLED=0`; связующий сервис qBittorrent ↔ Jellyfin, SQLite через
|
||||
`modernc.org/sqlite`). Твоя оптика — **архитектура, инварианты, конвенции, стиль
|
||||
и дублирование**. Находки пиши по-русски, идентификаторы и пути — в оригинале.
|
||||
Читай реальный код перед выводом, ничего не выдумывай.
|
||||
|
||||
## Контекст, который надо прочитать
|
||||
|
||||
`CLAUDE.md` (принципы, инварианты, конвенции кода), `docs/specs/architecture.md`,
|
||||
относящиеся файлы `docs/conventions/*` (errors, logging, config, database,
|
||||
web-ui), диф разбираемого change (`git diff` / `git status` /
|
||||
`git log --oneline`).
|
||||
|
||||
## Что проверяешь
|
||||
|
||||
- **Архитектурные границы.** Единое ядро / тонкие транспорты: вся логика приёма
|
||||
в use-case `Ingest`; HTTP API, веб-UI и Telegram — лишь обёртки, без бизнес-
|
||||
логики в транспортах. Размещение по пакетам `internal/<компонент>` согласно
|
||||
architecture.md. Минимум компонентов, без лишних сущностей.
|
||||
- **Инварианты безопасности данных.** Источник неприкосновенен: только `mkdir` /
|
||||
`link(2)` / `unlink` своих ссылок, никогда не трогаем файлы под
|
||||
`paths.downloads`. Целевой путь санитизируется и строго под
|
||||
`paths.movies`/`series` (защита от traversal), существующее не
|
||||
перезаписываем. Выход LLM недоверенный — безопасность на валидации пути.
|
||||
Секреты (пароли qBittorrent, API-ключи LLM/метабаз, auth-заголовки) не попадают
|
||||
в логи.
|
||||
- **Ошибки.** Stdlib, обёртка с контекстом (`fmt.Errorf("...: %w", err)`),
|
||||
проверка через `errors.Is`/`errors.As`, трансляция на внешней границе.
|
||||
- **Логирование.** Только `slog`, без `fmt.Println`; корректные уровни,
|
||||
обязательные поля, ничего секретного.
|
||||
- **Конфиг.** Только TOML, секреты из файла (не env), валидация на старте.
|
||||
- **Время.** UTC, RFC 3339 с суффиксом `Z`, генерирует только приложение
|
||||
(`store.Now()`); таймзона отображения — конфиг `[general].timezone`.
|
||||
- **Идентификаторы.** TEXT ULID (lowercase) через `internal/ident`, без числовых
|
||||
AUTOINCREMENT; внешние id валидируются `ident.Parse` на границе.
|
||||
- **Миграции.** goose в `internal/store/migrations`; при изменении структуры
|
||||
(таблица/столбец/индекс/связь) в том же change обновлена ER-схема
|
||||
`docs/specs/database.md`.
|
||||
- **Веб-UI (htmx).** Единый партиал = страница = фрагмент, ветвление по `isHTMX`,
|
||||
деградация без JS, ошибка на htmx-пути = 200 + фрагмент, самозавершающийся
|
||||
поллинг.
|
||||
- **Стиль и дублирование.** Код читается как окружающий (нейминг, плотность
|
||||
комментариев, идиомы). Ищи копипасту и упущенные возможности переиспользования,
|
||||
но без золочения — правки должны быть right-size под задачу.
|
||||
|
||||
Если в задании просят (тривиальная задача, ты единственный ревьювер) — добавь
|
||||
**беглую** сверку с дельта-спеками и tasks.md change: реализовано ли заявленное,
|
||||
нет ли забытых задач. Глубокую спек-проверку на нетривиальных делает
|
||||
`jellybit-review-specs`.
|
||||
|
||||
## Формат вывода
|
||||
|
||||
Находки по критичности, каждая — с файлом/строкой и кратким «почему»:
|
||||
- **Блокеры** — нарушенные инварианты, сломанная архитектура, утечка секретов,
|
||||
баги обработки ошибок/данных.
|
||||
- **Важное** — отступления от конвенций, дублирование, слабые места.
|
||||
- **Мелочь-инлайн** — то, что оркестратор поправит сам.
|
||||
- **Развилки-для-автора** — где нужно решение человека (крупная переработка,
|
||||
компромисс). Формулируй как вопрос с вариантами.
|
||||
|
||||
## Ограничения
|
||||
|
||||
Только чтение и анализ. Не редактируй код, не запускай сборку/тесты с
|
||||
сайд-эффектами, не коммить. Результат — текст находок для оркестратора.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: jellybit-review-specs
|
||||
description: Ревьювер спек и требований для jellybit (Spec Driven Development на OpenSpec). Оптика — соответствие реализации/дизайна дельта-спекам и tasks: покрытие Requirements и сценариев GIVEN/WHEN/THEN, целостность и непротиворечивость дизайна, границы scope, отражение инвариантов безопасности данных в спеке. Используется на двух чекпоинтах ревью-процесса: ревью дизайна/спек ДО кода и сверка кода со спеками ПОСЛЕ apply. Работает только на чтение, код не меняет.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
color: cyan
|
||||
---
|
||||
|
||||
Ты — ревьювер спецификаций проекта **jellybit** (Go, один статический бинарь;
|
||||
связующий сервис qBittorrent ↔ Jellyfin). Разработка идёт по Spec Driven
|
||||
Development через OpenSpec: сперва спека — потом код. Твоя оптика — **спеки и
|
||||
требования**, а не стиль кода. Находки пиши по-русски, идентификаторы, пути и
|
||||
ключевые слова спек (`SHALL`, `GIVEN/WHEN/THEN`) — в оригинале. Читай реальные
|
||||
файлы перед выводом, ничего не выдумывай.
|
||||
|
||||
## Контекст, который надо прочитать
|
||||
|
||||
Всегда сперва подними: `CLAUDE.md` (раздел «Инварианты» и «Spec Driven
|
||||
Development»), `openspec/changes/<id>/` разбираемого change (proposal.md,
|
||||
design.md, дельта-спеки с `ADDED/MODIFIED/REMOVED Requirements`, tasks.md),
|
||||
затронутые `openspec/specs/*/spec.md`, `docs/specs/architecture.md`. Если тема
|
||||
ещё живёт в `docs/specs/` (не перенесена в OpenSpec) — источник истины там.
|
||||
|
||||
## Два режима (что ревьюишь — скажут в задании)
|
||||
|
||||
1. **Дизайн/спеки ДО кода.** Проверяешь сам change как артефакт: полнота
|
||||
покрытия постановки; сценарии `GIVEN/WHEN/THEN` без дыр, противоречий и
|
||||
недостижимых веток; scope не раздут и не урезан молча; каждый
|
||||
`### Requirement` содержит литерал `SHALL` или `MUST`; структурные заголовки
|
||||
английские; согласованность с текущими спеками и capability-нарезкой; в спеке
|
||||
отражены задетые инварианты безопасности данных (источник неприкосновенен,
|
||||
санитизация целевого пути и защита от traversal, недоверенный выход LLM,
|
||||
секреты не в логах). Отметь, если `openspec validate --strict <id>` очевидно
|
||||
упадёт.
|
||||
2. **Код против спек ПОСЛЕ apply.** Сверяешь реализацию с дельта-спеками и
|
||||
tasks.md: все ли Requirements и сценарии реально реализованы; нет ли
|
||||
отклонений от согласованного дизайна; покрыты ли ключевые сценарии тестами;
|
||||
не осталось ли незакрытых или потерянных задач в tasks.md. Диф бери через
|
||||
`git diff` / `git status` / `git log --oneline`.
|
||||
|
||||
## Метод
|
||||
|
||||
1. Выпиши нумерованный чек-лист Requirements и сценариев из дельта-спек.
|
||||
2. Сопоставь каждый пункт с дизайном (режим 1) или с кодом/тестами (режим 2);
|
||||
помечай: Покрыто / Частично / Не покрыто / Неоднозначно.
|
||||
3. Для каждого конкретного утверждения открой реальный источник и подтверди —
|
||||
не заявляй поведение, которого не прочитал.
|
||||
4. Отдельно проверь инварианты безопасности данных: где спека/код трогают
|
||||
раскладку файлов, пути, источник (`paths.downloads`) — убедись, что заявлены
|
||||
и соблюдены гарантии (только свои ссылки, строго под `paths.movies`/`series`,
|
||||
существующее не перезаписываем).
|
||||
|
||||
## Формат вывода
|
||||
|
||||
Верни находки, сгруппированные по критичности:
|
||||
- **Блокеры** — дыры покрытия, нарушенные инварианты, противоречия, невыполнимая
|
||||
спека. Каждый — с указанием файла/пункта и кратким «почему».
|
||||
- **Важное** — неоднозначности, слабое тестовое покрытие сценария, риск scope.
|
||||
- **Мелочь-инлайн** — то, что оркестратор поправит сам без обсуждения.
|
||||
- **Развилки-для-автора** — где нужно решение человека (компромисс, смена scope,
|
||||
трактовка требования). Формулируй как вопрос с вариантами.
|
||||
|
||||
## Ограничения
|
||||
|
||||
Только чтение и анализ. Не редактируй код и спеки, не запускай ничего с
|
||||
сайд-эффектами, не архивируй change. Твой результат — текст находок для
|
||||
оркестратора, а не правки.
|
||||
@@ -7,6 +7,8 @@ tags: [workflow, artifacts, experimental]
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
@@ -46,8 +48,6 @@ Implement tasks from an OpenSpec change.
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
**Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files.
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||
|
||||
@@ -7,6 +7,8 @@ tags: [workflow, archive, experimental]
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
@@ -29,8 +31,6 @@ Archive a completed change in the experimental workflow.
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace archive is not supported in this slice and STOP. Do not move workspace changes into repo-local archives or edit linked repos.
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Prompt user for confirmation to continue
|
||||
|
||||
@@ -11,6 +11,8 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
|
||||
- A vague idea: "real-time collaboration"
|
||||
- A specific problem: "the auth system is getting unwieldy"
|
||||
|
||||
@@ -16,6 +16,8 @@ When ready to implement, run /opsx:apply
|
||||
|
||||
---
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -9,6 +9,8 @@ Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
@@ -28,8 +30,6 @@ This is an **agent-driven** operation - you will read delta specs and directly e
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
|
||||
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace spec sync is not supported in this slice and STOP. Do not fall back to repo-local paths or edit linked repos.
|
||||
|
||||
3. **Find delta specs**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
|
||||
|
||||
@@ -6,11 +6,13 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.4.1"
|
||||
generatedBy: "1.5.0"
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
@@ -50,8 +52,6 @@ Implement tasks from an OpenSpec change.
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
**Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files.
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||
|
||||
@@ -6,11 +6,13 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.4.1"
|
||||
generatedBy: "1.5.0"
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
@@ -33,8 +35,6 @@ Archive a completed change in the experimental workflow.
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace archive is not supported in this slice and STOP. Do not move workspace changes into repo-local archives or edit linked repos.
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.4.1"
|
||||
generatedBy: "1.5.0"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
@@ -15,6 +15,8 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.4.1"
|
||||
generatedBy: "1.5.0"
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
@@ -20,6 +20,8 @@ When ready to implement, run /opsx:apply
|
||||
|
||||
---
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -6,13 +6,15 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.4.1"
|
||||
generatedBy: "1.5.0"
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
@@ -32,8 +34,6 @@ This is an **agent-driven** operation - you will read delta specs and directly e
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
|
||||
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace spec sync is not supported in this slice and STOP. Do not fall back to repo-local paths or edit linked repos.
|
||||
|
||||
3. **Find delta specs**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
name: task-pipeline
|
||||
description: Автономно проводит задачу jellybit через полный цикл SDD — от выбора в беклоге до коммита (opsx explore→propose→ревью спек→apply→ревью кода→archive→чистка беклога). Использовать, когда пользователь просит взять/сделать задачу из беклога или довести идею до реализации.
|
||||
---
|
||||
|
||||
# Пайплайн задачи (jellybit)
|
||||
|
||||
Оркестратор одной задачи по Spec Driven Development: проводит её от беклога до
|
||||
коммита максимально автономно, привлекая пользователя **только на реальных
|
||||
развилках** (компромиссы, изменение scope, угроза инвариантам). Механику не
|
||||
согласовываем — делаем.
|
||||
|
||||
Перед стартом прочитай `CLAUDE.md`, а также `README.md`, `BRIEF.md`,
|
||||
`docs/specs/architecture.md`, если ещё не в контексте. Это тонкая обёртка над
|
||||
каноническими скиллами `opsx:explore` / `opsx:propose` / `opsx:apply` /
|
||||
`opsx:archive` — вызывай их через Skill, не переизобретай их шаги.
|
||||
|
||||
## Принцип автономности
|
||||
|
||||
Зови пользователя (через **AskUserQuestion**) только когда решение реально его:
|
||||
|
||||
- **Выбор задачи**, если он не задан явно.
|
||||
- **Развилки грумминга** на explore: несколько равнозначных направлений,
|
||||
спорный scope, продуктовый компромисс.
|
||||
- **Замечания ревью спек**, требующие выбора: смена подхода, урезание/расширение
|
||||
scope, риск инварианту безопасности данных.
|
||||
- Всё остальное — механика: делаем без спроса. Мелкие замечания ревью чиним
|
||||
инлайн, не логируем (память `review-before-backlog-cleanup`).
|
||||
|
||||
Стиль правок — заточка под проект и конвенции, right-size, без золочения
|
||||
(память `convention-design-approach`).
|
||||
|
||||
## Шаги
|
||||
|
||||
### 1. Выбрать / прочитать задачу
|
||||
|
||||
- Если задача задана (slug, файл в `docs/backlog/`, ссылка Tududi или описание) —
|
||||
прочитай её файл и связанные спеки/ADR/черновики.
|
||||
- Если не задана — покажи топ-кандидатов из `docs/backlog/README.md` (высокий
|
||||
приоритет, не `[идея]`) через **AskUserQuestion** и дай выбрать.
|
||||
- Задача с префиксом `[идея]` (ещё без решения «делаем») — сперва обязательно
|
||||
через explore (шаг 2), там она либо становится задачей, либо остаётся идеей.
|
||||
|
||||
Оцени тривиальность (влияет на шаг 4):
|
||||
- **Тривиальная** — локальная правка без изменения поведения/спек/схемы БД,
|
||||
очевидное решение. Explore и ревью спек пропускаем.
|
||||
- **Нетривиальная** — новое/изменённое поведение, дизайн-развилки, затрагивает
|
||||
инварианты, схему БД или несколько capability. Полный цикл.
|
||||
|
||||
### 2. (Опц.) Груммить идею — `opsx:explore`
|
||||
|
||||
Только для `[идея]`-задач или когда постановка мутная. Вызови Skill
|
||||
`opsx:explore`. Развилки грумминга — на пользователя (AskUserQuestion). Выход:
|
||||
ясная постановка, готовая к propose. **В explore не пишем код.**
|
||||
|
||||
### 3. Завести change — `opsx:propose`
|
||||
|
||||
Вызови Skill `opsx:propose`. Получаем `proposal.md`, дизайн (для нетривиальных),
|
||||
дельта-спеки (`ADDED`/`MODIFIED`/`REMOVED Requirements`), `tasks.md`. Каждое
|
||||
`### Requirement` содержит `SHALL`/`MUST`; структурные заголовки английские,
|
||||
сценарии `GIVEN/WHEN/THEN`. Прогони `openspec validate --strict <id>`.
|
||||
|
||||
### 4. (Нетривиальная) Ревью спек — сабагент, ДО кода
|
||||
|
||||
Первый чекпоинт ревью-процесса из CLAUDE.md. Запусти **один** сабагент
|
||||
`jellybit-review-specs` (Agent tool, `subagent_type`) в режиме «дизайн/спеки ДО
|
||||
кода». Charter самодостаточен — дай ссылку на change `<id>`. Агент проверит
|
||||
полноту покрытия, сценарии `GIVEN/WHEN/THEN`, scope, инварианты безопасности
|
||||
данных, согласованность со спеками и capability-нарезкой, наличие `SHALL`/`MUST`.
|
||||
|
||||
### 5. Отработать замечания ревью спек
|
||||
|
||||
- Мелочь и явные улучшения — правь сам в спеках/дизайне.
|
||||
- Развилки (компромисс, scope, инвариант) — на пользователя (AskUserQuestion).
|
||||
- После правок перепрогони `openspec validate --strict <id>`.
|
||||
|
||||
### 6. Написать код — `opsx:apply`
|
||||
|
||||
Вызови Skill `opsx:apply` для реализации `tasks.md`. Код по конвенциям
|
||||
`docs/conventions/*`: ошибки stdlib с `%w`/`errors.Is`, логи только `slog` без
|
||||
секретов, время в UTC через `store.Now()`, ULID через `internal/ident`, миграции
|
||||
goose + синк ER-схемы `docs/specs/database.md`, htmx по web-ui-конвенции.
|
||||
Прогони `task test` и `task lint` (или `task build`), добейся зелёного.
|
||||
|
||||
### 7. Ревью кода — сабагент(ы)
|
||||
|
||||
Второй чекпоинт. Ревьюеры — кастомные агенты из `.claude/agents/` (запускай их
|
||||
через Agent tool с `subagent_type`). Число зависит от тривиальности:
|
||||
|
||||
- **Тривиальная задача — один сабагент** `jellybit-review-code`. В промпте
|
||||
добавь просьбу дополнительно **бегло сверить соответствие дельта-спекам и
|
||||
tasks.md** (он единственный, покрывает и спеки, и конвенции).
|
||||
- **Нетривиальная — два параллельных сабагента одним сообщением**, чтобы шли
|
||||
конкурентно: `jellybit-review-specs` (оптика спек) и `jellybit-review-code`
|
||||
(оптика архитектуры/конвенций/стиля).
|
||||
|
||||
Charter'ы агентов самодостаточны — детальный промпт писать не нужно, дай ссылку
|
||||
на change (`<id>`) и diff/список файлов (`git diff`).
|
||||
|
||||
Отработай так же, как шаг 5: мелочь чини инлайн, развилки — на пользователя.
|
||||
После правок — снова `task test`/`task lint`.
|
||||
|
||||
### 8. Архивировать — `opsx:archive`
|
||||
|
||||
Вызови Skill `opsx:archive`: change уезжает в `openspec/changes/archive/`,
|
||||
дельты вливаются в `openspec/specs/`.
|
||||
|
||||
### 9. Закрыть беклог и синк доков
|
||||
|
||||
Ревью выполненного — **до** чистки (память `review-before-backlog-cleanup`).
|
||||
Затем:
|
||||
- Удали файл задачи `docs/backlog/<slug>.md` и строку в `docs/backlog/README.md`
|
||||
(реализованное не держим в беклоге — CLAUDE.md).
|
||||
- Суть переехавшего решения — в `docs/specs`/`docs/adr`, если ещё не там.
|
||||
- Если менялась структура БД — убедись, что ER-схема `docs/specs/database.md`
|
||||
обновлена в этом же change.
|
||||
|
||||
### 10. Коммит
|
||||
|
||||
Коммить **прямо в master**, без feature-веток (память
|
||||
`commit-directly-to-master`). Сообщение — по-русски, в стиле недавних коммитов
|
||||
(`git log --oneline -8`): область + суть. Одна задача — один осмысленный коммит
|
||||
(или несколько по фазам, если так шёл apply).
|
||||
|
||||
Готово — доложи пользователю кратко: что сделано, какие развилки решались, ссылки
|
||||
на архивный change и спеки.
|
||||
|
||||
## Тонкости
|
||||
|
||||
- Не пропускай `openspec validate --strict` перед архивацией.
|
||||
- Тривиальная задача: шаги 2 и 4 пропускаются; ревью кода (шаг 7) оставляем, но
|
||||
одним сабагентом на всё. Два параллельных ревьювера — только на нетривиальных.
|
||||
- Если сабагент-ревьюер сам предлагает крупную переработку — это развилка, не
|
||||
правь молча, вынеси пользователю.
|
||||
- Держи пользователя в цикле короткими репликами на переходах фаз, но не проси
|
||||
подтверждать механику.
|
||||
@@ -182,9 +182,17 @@ func runServe(args []string) error {
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
// Внутренние логи tgbotapi (сбои long-poll getUpdates и пр.) — в наш slog
|
||||
// вместо stdlib log мимо структурированных логов; токен вырезается.
|
||||
if lerr := tgbot.SetLibraryLogger(logger, cfg.Telegram.Token); lerr != nil {
|
||||
logger.Warn("telegram library logger not set", "error", lerr)
|
||||
}
|
||||
api, terr := tgbotapi.NewBotAPIWithClient(cfg.Telegram.Token, tgbotapi.APIEndpoint, tgClient)
|
||||
if terr != nil {
|
||||
logger.Error("telegram bot disabled, cannot connect", "error", terr)
|
||||
// NewBotAPIWithClient дёргает getMe: при недоступном Telegram/прокси
|
||||
// terr — *url.Error с URL …/bot<TOKEN>/getMe; санитизируем, чтобы
|
||||
// токен не утёк в лог.
|
||||
logger.Error("telegram bot disabled, cannot connect", "error", logging.SanitizeErr(terr))
|
||||
} else {
|
||||
bot := tgbot.New(api, ingestor, wrk, tgbot.Config{
|
||||
AllowedUserIDs: cfg.Telegram.AllowedUserIDs,
|
||||
|
||||
@@ -16,19 +16,14 @@ Tududi (проект `jellybit`) больше **не** держит беклог
|
||||
|
||||
## Высокий
|
||||
|
||||
- [Проблема второго сезона (сходимость папки сериала)](vtoroy-sezon-shodimost-papki.md) — Второй/третий сезон должен ложиться в ТУ ЖЕ папку сериала, а не заводить рядом почти…
|
||||
- [Раздачи с докачиванием (merge при повторном добавлении)](merge-dokachivanie.md) — Свежий сериал раздают по мере выхода: торрент с 5 из 10 эпизодов позже перезаливают…
|
||||
- [Полное удаление загрузки из jellybit («единое окно», path 2)](udalenie-edinoe-okno.md) — Действие «Удалить»: снять хардлинки + снести раздачу+файлы из qBittorrent → освободить место (отдельно от undo)
|
||||
- [Ретеншн и очистка БД](retention-ochistka-bd.md) — Терминальные задачи (done/cancelled/failed/reverted), их попытки recognition с сырыми…
|
||||
- [Eval-харнес распознавания (корпус кейсов + метрика точности)](eval-harness-raspoznavaniya.md) — Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую…
|
||||
- [Гейт дозаписи хешей в dedup-ветке CreateDownloadIfNoActive (F1)](review-f1-gate-dozapisi-heshey.md) — dedup-ветка дописывает все хеши в найденную задачу без гарда — риск инварианта ≤1 активной _(ревью 2026-07-08)_
|
||||
- [Retry/stall семантика: сброс базиса таймаута + простой от начала, а не от возраста торрента (MAJOR-1, MAJOR-2)](review-major1-2-retry-stall.md) — таймаут и простой отсчитываются от возраста торрента, а не от начала загрузки _(ревью 2026-07-08)_
|
||||
- [Восстановление zombie downloading при пропаже источника из qBittorrent (MAJOR-3)](review-major3-zombie-downloading.md) — торрент пропал из qBittorrent в downloading → задача вечный зомби, никто не двигает _(ревью 2026-07-08)_
|
||||
|
||||
## Средний
|
||||
|
||||
- [Словарь единого языка (ubiquitous language)](ubiquitous-language-slovar.md) — Свести термины домена в один глоссарий, чтобы пользователь, документация, код и агент…
|
||||
- [Агенты-ревьюверы качества (наименования, архитектура, конвенции, стиль)](agenty-revyuvery-kachestva.md) — Набор узких сабагентов-ревьюверов поверх ревью-процесса из CLAUDE
|
||||
- [Агенты-ревьюверы качества (наименования, архитектура, конвенции, стиль)](agenty-revyuvery-kachestva.md) — Ядро (specs+code ревьюверы) сделано и вшито в task-pipeline; остался ревьювер наименований (ждёт словарь единого языка)
|
||||
- [[идея] Сила совпадения кандидата и пересмотр распознавания/матчинга](sila-sovpadeniya-kandidata.md) — ИДЕЯ (сперва проработать)
|
||||
- [История переходов загрузки](istoriya-perehodov-zagruzki.md) — Сохранять полную историю переходов состояний загрузки (что/когда/почему/кто инициировал…
|
||||
- [Привязка уведомлений к источнику в ботах (мульти-бот)](uvedomleniya-multi-bot.md) — Уведомления и запросы подтверждения должен получать тот, кто прислал загрузку: автор…
|
||||
@@ -40,22 +35,21 @@ Tududi (проект `jellybit`) больше **не** держит беклог
|
||||
- [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать…
|
||||
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
|
||||
- [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
|
||||
- [Sweep застрявших linking при рестарте + фикс persist-failure (MAJOR-4)](review-major4-sweep-linking.md) — задачи застревают в linking при рестарте; заодно фикс persist-failure _(ревью 2026-07-08)_
|
||||
- [Defer из catched → лимбо → необратимый deleted (MAJOR-6)](review-major6-defer-catched.md) — Defer из ещё-не-добавленного catched уводит задачу в необратимый deleted _(ревью 2026-07-08)_
|
||||
- [processCatched: promote-without-add если торрент уже в qBittorrent (F2)](review-f2-promote-without-add.md) — торрент уже в qBittorrent → processCatched зациклен на Add вместо promote _(ревью 2026-07-08)_
|
||||
- [Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)](review-f3-cancel-during-add.md) — Cancel во время add оставляет неуправляемый торрент в qBittorrent _(ревью 2026-07-08)_
|
||||
- [transition() глотает ошибки перед созданием хардлинков (MINOR-7)](review-minor7-transition-errors.md) — transition() глотает ошибку перед хардлинками — задача застревает в review _(ревью 2026-07-08)_
|
||||
- [.torrent поверх magnet при дедупе теряет байты — потерян upgrade-путь (F6)](review-f6-torrent-over-magnet.md) — .torrent поверх magnet при дедупе теряет байты — потерян upgrade-путь _(ревью 2026-07-08)_
|
||||
|
||||
## Низкий
|
||||
|
||||
- [Панель действий ревью вне htmx-свопа блока источника](panel-review-vne-swap.md) — При выборе источника одним кликом обновляется только блок источника (#source-block)…
|
||||
- [Мгновенные обновления через SSE](sse-obnovleniya.md) — Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает…
|
||||
- [Шум ERROR фоновых циклов при недоступной зависимости](oshibki-klassifikaciya-i-konvencii-logirovaniya.md) — Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_
|
||||
- [Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)](versii-kachestvo-repaki.md) — По калибровке болей (2026-07-02) — не боль, из приоритета выпало
|
||||
- [[идея] Многоступенчатая верификация привязки](mnogostupenchataya-verifikaciya.md) — ИДЕЯ (требует проработки)
|
||||
- [Выбор из нескольких находок метабазы в Telegram](telegram-vybor-nahodok.md) — Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в…
|
||||
- [Улучшения UI: показывать матч с записью метабазы в Telegram](telegram-match-metabazy.md) — Название/год/провайдер+id в боте уже выводятся; осталась кликабельная ссылка на запись
|
||||
- [Добавление торрентов файлом/ссылкой — «единое окно» (остаток: URL)](dobavlenie-edinoe-okno.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард)
|
||||
- [Форма приёма: длинное имя .torrent-файла ломает верстку add-row](torrent-fayl-dlinnoe-imya.md) — длинное имя файла распирает лейбл файл-пикера, поле source сжимается — нужен ellipsis
|
||||
- [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](disk-kopii-video-ts-bdmv.md) — Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска…
|
||||
- [Проверка свободного места перед copy-fallback](svobodnoe-mesto-copy-fallback.md) — Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске
|
||||
- [Кэш метабаз (и опционально LLM)](kesh-metabaz.md) — Повторные и ретраящиеся прогоны распознавания бьют TMDB/TVDB/TVMaze одним и тем же…
|
||||
|
||||
@@ -4,4 +4,21 @@
|
||||
|
||||
Набор узких сабагентов-ревьюверов поверх ревью-процесса из CLAUDE.md, каждый со своей оптикой: соответствие наименований словарю единого языка, соблюдение архитектурных границ (единое ядро/тонкие транспорты, инварианты безопасности данных), конвенций (ошибки, логирование, конфиг, TZ), стиля кода и поиск дублирования. Запускаются как чекпоинт перед archive/коммитом. Развивает ревью-процесс OpenSpec в сторону воспроизводимых автопроверок, не заменяя человеческое ревью.
|
||||
|
||||
Связано: CLAUDE.md (ревью-процесс, конвенции), docs/conventions, «Словарь единого языка».
|
||||
## Сделано (2026-07-10)
|
||||
|
||||
- Заведены два кастомных ревьювера в `.claude/agents/`: `jellybit-review-specs`
|
||||
(оптика спек/требований) и `jellybit-review-code` (архитектура, инварианты,
|
||||
конвенции, стиль, дублирование).
|
||||
- Оба подключены как чекпоинт в скилл `.claude/skills/task-pipeline` (ревью спек
|
||||
ДО кода + ревью кода перед archive; на тривиальной задаче — один
|
||||
`jellybit-review-code`, на нетривиальной — оба параллельно).
|
||||
|
||||
## Осталось
|
||||
|
||||
- **Ревьювер наименований** (соответствие словарю единого языка) — отдельной
|
||||
оптикой пока не выделен: зависит от задачи «Словарь единого языка
|
||||
(ubiquitous language)», без глоссария проверять не по чему. Завести после неё.
|
||||
- По опыту эксплуатации — решить, дробить ли `jellybit-review-code` на более
|
||||
узкие оптики (архитектура / конвенции / стиль+дублирование) или оставить одним.
|
||||
|
||||
Связано: CLAUDE.md (ревью-процесс, конвенции), docs/conventions, «Словарь единого языка», скилл `task-pipeline`.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Шум ERROR фоновых циклов при недоступной зависимости
|
||||
|
||||
**Приоритет:** низкий · **Теги:** review-fable, logging, reliability
|
||||
|
||||
Остаток от задачи «классификация доменных ошибок + конвенции логирования»
|
||||
(основное реализовано, см. ниже). Здесь — два смежных пункта про уровень
|
||||
повторяющихся сбоев фоновых циклов, каждый требует небольшого решения, а не
|
||||
только правки.
|
||||
|
||||
## Что уже сделано (не переоткрывать)
|
||||
|
||||
Коммит `f8fb4fa` (Tier A) + коммит этой задачи закрыли:
|
||||
|
||||
- **Классификация доменных ошибок:** sentinel `worker.ErrInvalidInput`→400;
|
||||
обёртки `ErrConflict` в Cancel/Retry/Defer/Undo; `layout.ErrCollision`→409 в
|
||||
`classifyErr` и ветка в tgbot; `logCmd` относит новые классы в DEBUG.
|
||||
- **Конвенции:** `logging.md` — команды воркера = доменная граница, таблица
|
||||
уровней доменных отказов (граница команды vs асинхронная стадия), правило про
|
||||
`*url.Error`/секреты в URL, канон категории `state transition` (унифицированы
|
||||
cancel/retry/relink/recovery). `errors.md` — таблица маппинга ошибка→статус,
|
||||
развилка «транзиентный ответ vs персистентная диагностика» решена как (а):
|
||||
`error_msg`/`reasons` — операторская поверхность владельца (сырой текст ок,
|
||||
секреты запрещены; аудит показал, что секреты туда не текут).
|
||||
- **Мелочи:** reason-коды const-блок; лог-поля `id`→`download_id`; preview
|
||||
WARN; комментарий у `parseIgnored`.
|
||||
|
||||
## Остаток
|
||||
|
||||
### ERROR-шторм при недоступном qBittorrent
|
||||
|
||||
Клиент `qbt` логирует `ext.*` `Failure` → **ERROR** на каждом тике поллинга
|
||||
(`torrents/info`, `internal/qbt/qbt.go`), пока qBittorrent недоступен (рестарт
|
||||
демона, сеть). Домен уже пишет `poll failed` = WARN (по новой конвенции), но
|
||||
транспортная `ext.*`-запись остаётся ERROR по правилу ext-конвенции («сервис
|
||||
недоступен → ERROR»). При частом поллинге это шумит.
|
||||
|
||||
Развилка (решить до правки):
|
||||
|
||||
- (а) Ввести у `logging.ExtCall` вариант с пониженным уровнем для рутинно-частых
|
||||
вызовов (симметрично `SuccessDebug`) — поллинг-вызовы (`torrents/info`) на
|
||||
транзиентном сбое пишут WARN, не ERROR;
|
||||
- (б) Дедуп/circuit-breaker: первый ERROR, дальше тишина до восстановления;
|
||||
- (в) Оставить как есть, признав `ext.*` ERROR легитимным сигналом «зависимость
|
||||
лежит» (тогда шум гасить уровнем сбора, а не кодом).
|
||||
|
||||
### Эскалация устойчивого сбоя тика
|
||||
|
||||
Сейчас транзиентный сбой тика = WARN всегда. Договорённость на будущее
|
||||
(`logging.md`): устойчивый сбой N тиков подряд эскалировать в ERROR (реальная
|
||||
деградация, а не разовый промах). Не реализовано — нужен счётчик подряд-сбоев по
|
||||
циклу и порог в конфиге.
|
||||
|
||||
Вердикт: мелкая надёжностная полировка, не блокер. Делать вместе (обе про
|
||||
уровень сбоев фоновых циклов) или отдельной строкой.
|
||||
@@ -1,13 +0,0 @@
|
||||
# Гейт дозаписи хешей в dedup-ветке CreateDownloadIfNoActive (F1)
|
||||
|
||||
**Приоритет:** высокий · **Теги:** ingest, review-2026-07-08, invariant
|
||||
|
||||
Ревью Fable 2026-07-08 (приём). internal/store/download.go:271-290.
|
||||
|
||||
Проблема: dedup-ветка CreateDownloadIfNoActive безусловно дописывает ВСЕ хеши norm в найденную активную задачу (INSERT OR IGNORE) без пер-хеш гарда владения — в отличие от AddInfohashes (download.go:382-418), у которого гард есть. Единственная неохраняемая запись хешей — в авторитетном методе инварианта.
|
||||
|
||||
Сценарий: активная A владеет v1, активная B владеет v2 того же торрента (split-identity, см. F4) ИЛИ крафт-магнет (F5) → гибрид {v1,v2} дописывает v1 в B → две активные владеют v1. Инвариант «≤1 активная на infohash» нарушен. Спека ingest «Атомарность возврата в активное» это запрещает.
|
||||
|
||||
Фикс: применить пер-хеш гард как в AddInfohashes (исключить existing.ID, пропускать хеши чужой активной задачи). Tx уже открыта.
|
||||
|
||||
Вердикт: простой фикс (поведение уже обещано спекой).
|
||||
@@ -10,4 +10,10 @@
|
||||
|
||||
Фикс: перед Add проверить присутствие хешей в qBit; есть → promote без Add (зеркалит Retry).
|
||||
|
||||
Смежное (ревью 2026-07-08, кластер A): апгрейд F6 (`UpgradeCatchedMagnetToTorrent`)
|
||||
оставил узкое окно — `processCatched` читает снимок `source_type` вне `w.mu`, поэтому
|
||||
при точном оверлапе тика воркера с апгрейдом воркер добавит magnet из устаревшего
|
||||
снимка, хотя БД уже `torrent`. Тот же фикс закрывает и это: перечитать источник под
|
||||
`w.mu` (или проверить присутствие хешей в qBit) перед Add.
|
||||
|
||||
Вердикт: change (малая спека-дельта download-tracking + код).
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# .torrent поверх magnet при дедупе теряет байты — потерян upgrade-путь (F6)
|
||||
|
||||
**Приоритет:** средний · **Теги:** ingest, review-2026-07-08
|
||||
|
||||
Ревью Fable 2026-07-08 (приём). ingest.go:85-90, download.go:252-253, спека ingest «при дедупликации байты сохраняться SHALL NOT».
|
||||
|
||||
Сценарий: magnet с приватного трекера → catched, source_type=magnet. Юзер понимает, что magnet не докачает метаданные (нет DHT), грузит правильный .torrent. Ingest дедупит по infohash на magnet-задачу; по спеке блоб НЕ сохраняется, source_type остаётся magnet. Worker добавляет по magnet-URL → metaDL вечно → failed/magnet_timeout через 24ч. Юзер дал именно артефакт, который бы починил, — выброшен с «уже в работе». Retry снова по magnet. Рационал самой спеки (хранить байты, «иначе на закрытых трекерах не докачать») спорит с её же правилом дедупа здесь.
|
||||
|
||||
Фикс: при дедупе, где входящее — torrent-байты, а existing — catched с source_type=magnet: сохранить блоб и сменить source_type в той же tx.
|
||||
|
||||
Вердикт: change (противоречит текущему предложению спеки, нужна дельта).
|
||||
@@ -1,13 +0,0 @@
|
||||
# Retry/stall семантика: сброс базиса таймаута + простой от начала, а не от возраста торрента (MAJOR-1, MAJOR-2)
|
||||
|
||||
**Приоритет:** высокий · **Теги:** review-2026-07-08, lifecycle
|
||||
|
||||
Ревью Fable 2026-07-08 (жизненный цикл). Два связанных бага. worker.go:677-734 (Retry), :514-525 (checkTimeouts), :578-592 (torrentAge).
|
||||
|
||||
MAJOR-1: Retry с живым торрентом (alive=true) не переиздаёт Add, только ActivateIfNoOtherActive→downloading; базис age=now−added_on НЕ сбрасывается → следующий тик: stalledDL && age>StuckAfter → снова stuck (~5с). Спека state-reconciliation «Ручной повтор» требует: базис SHALL сбрасываться. Комментарий worker.go:690-691 верен лишь для re-Add ветки. Тест TestRetryReattaches не гоняет следующий тик.
|
||||
|
||||
MAJOR-2: stuck_after меряет ВОЗРАСТ торрента (от added_on), а не длительность простоя. Торрент, качавшийся 5ч, при мгновенном stalledDL на 1 тик → stuck с сообщением «stalled for 5h» (ложь) + EventFailed. Проход через stalledDL между пирами — норма → флап stuck↔downloading + до-часовые ложные уведомления. Спека сама противоречива («stalledDL дольше stuck_after» vs «возраст от added_on»).
|
||||
|
||||
Фикс: колонка retried_at и/или stalled_since (или qBit last_activity); базис = max(added_on, retried_at); простой мерить от stalled_since. Схема + миграция + сверка спеки. Покрывает также NIT-10 (фолбек added_on→created_at) и NIT-12 (retry на qbit_error мгновенно откатывается).
|
||||
|
||||
Вердикт: полноценный change (схема + спека). Бьёт по повседневным сценариям — retry выглядит сломанным, длинные загрузки спонтанно флапают в stuck.
|
||||
@@ -1,11 +0,0 @@
|
||||
# Восстановление zombie downloading при пропаже источника из qBittorrent (MAJOR-3)
|
||||
|
||||
**Приоритет:** высокий · **Теги:** review-2026-07-08, lifecycle
|
||||
|
||||
Ревью Fable 2026-07-08 (жизненный цикл). worker.go:472-477. Подтверждено чтением кода.
|
||||
|
||||
Сценарий: торрент удалён из qBittorrent (юзером/другим клиентом), пока задача в downloading. Poll: torrentFor промах → Warn «active download not found in qbittorrent» → continue. Каждый тик, вечно. reconcileDesync покрывает только done/target_missing/orphaned; reconcileRecovery — failed/stuck; дебаунса для этого случая НЕТ, состояние не меняется, уведомления нет, checkTimeouts требует торрент. Задача — вечный зомби, активна в UI без телеметрии; выход только Cancel/Defer. Тот же зомби при провале отката Retry (worker.go:716-729). Спека намеренно исключает активные из матрицы source×target, но «источник исчез в downloading» не владеет НИКТО — дыра спеки (сравн.: та же пропажа в completed/recognizing деградирует штатно).
|
||||
|
||||
Фикс: расширить дебаунс пропажи источника (SourceMissCount) на downloading → после порога downloading→deleted (или failed с distinct error_code для re-Add) + уведомление. Нужно ребро графа.
|
||||
|
||||
Вердикт: полноценный change. Классический «застрявшее состояние, которое никто не двигает».
|
||||
@@ -1,13 +0,0 @@
|
||||
# Sweep застрявших linking при рестарте + фикс persist-failure (MAJOR-4)
|
||||
|
||||
**Приоритет:** средний · **Теги:** review-2026-07-08, lifecycle
|
||||
|
||||
Ревью Fable 2026-07-08 (жизненный цикл). review.go:284-287.
|
||||
|
||||
(A) Без краха: linkPlan создаёт хардлинки на FS, затем CreateFileLinks падает (транзиентная ошибка SQLite) → return без перехода → задача в linking, хардлинки на диске без file_link-строк (Undo нечего откатывать, targetPresent=false).
|
||||
|
||||
(B) Краш процесса между transition(StateLinking) (review.go:251) и финальным переходом → на рестарте linking не листит НИКТО (processCatched=catched, Poll=downloading, recognizePending=completed/recognizing, desync=done/tm/orphaned, recovery=failed/stuck). Задача сидит в linking вечно; выход только ручной Cancel/Defer (недискаверабельно). recognizing получил restart-healing (recognizePending), linking — нет — нарушен инвариант «у каждого нетерминального состояния есть владелец».
|
||||
|
||||
Фикс: на тике/старте sweep linking-задач (любая под w.mu — по построению устаревшая) → linking→review (ребро есть) с error_msg «прерванная раскладка, повтори»; при persist-failure переходить в review/failed, а не bare-return.
|
||||
|
||||
Вердикт: простой фикс (+ 1 спека-сценарий).
|
||||
@@ -1,11 +0,0 @@
|
||||
# transition() глотает ошибки перед созданием хардлинков (MINOR-7)
|
||||
|
||||
**Приоритет:** средний · **Теги:** review-2026-07-08, lifecycle
|
||||
|
||||
Ревью Fable 2026-07-08 (жизненный цикл). worker.go:595-601 (ошибка логируется, не возвращается), review.go:251-252, :196-198.
|
||||
|
||||
Сценарий: в Apply w.transition(StateLinking) на транзиентной ошибке БД → залогировано, выполнение продолжается → linkPlan создаёт хардлинки, пока задача ещё в review. Финальная запись linking→done оценивается как review→done — НЕ в графе → отклонена → файлы на диске, задача застряла в review со stale-планом; file_link-строки есть (re-Apply увидит StatusExists, частично самолечится), но done не достигнут, скан/уведомление не сработали. Паттерн claim-then-side-effect корректен только если claim проверяется (везде ещё — PromoteCatched, finishRecognition — гейтят; тут нет).
|
||||
|
||||
Фикс: transition возвращает ошибку (или mustTransition); Apply/finishRecognition прерываются до linkPlan при провале claim.
|
||||
|
||||
Вердикт: простой фикс.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Форма приёма: длинное имя .torrent-файла ломает верстку add-row
|
||||
|
||||
**Приоритет:** низкий · **Теги:** web-ui, ingest
|
||||
|
||||
При выборе `.torrent`-файла с длинным именем (типичный релиз с трекера, напр.
|
||||
«Бухта вдов Widow's Bay Сезон 1 Серии 1-10 из 10 (Хиро Мурай) [2026, США, …,
|
||||
WEB-DL 1080p] 7 x MVO (HDr [rutracker-6869937].torrent») кнопка-лейбл файл-пикера
|
||||
раздувается на всю ширину и ломает раскладку строки добавления: поле `source`
|
||||
сжимается/прячется, «Добавить» уезжает.
|
||||
|
||||
Причина: onchange пишет полное `files[0].name` в `[data-file-label]`
|
||||
(`web/templates/index.html:26-30`), а у `.btn-file` нет ограничения ширины
|
||||
(`web/static/css/jellybit.css:194` — только цвет). В `.add-row`
|
||||
(`display:flex`, `.input{flex:1}`) лейбл распирается по контенту и съедает строку.
|
||||
|
||||
Фикс (косметика, без домена):
|
||||
- CSS `.btn-file`: `max-width` + `overflow:hidden; text-overflow:ellipsis;
|
||||
white-space:nowrap` — имя усекается многоточием, а не растягивает строку.
|
||||
- Опц.: показывать базовое имя без `.torrent` и класть полное в `title`
|
||||
(тултип). Полное имя всё равно уходит на сабмит через сам file-input
|
||||
(`header.Filename`), правка чисто визуальная.
|
||||
|
||||
Смежное (проверить заодно, не обязательно тем же фиксом): на приёме `source_ref`
|
||||
выводится из имени раздачи/файла и может быть очень длинным. В списке/карточке он
|
||||
усечён (`shorten(oneLine(d.SourceRef), 120)` — `internal/httpapi/download.go:67`),
|
||||
так что отображение ок; убедиться лишь, что нет жёсткого лимита колонки в БД,
|
||||
роняющего сам приём длинного имени.
|
||||
|
||||
Связано: `openspec/specs/ingest`, `openspec/specs/web-ui`, пакет `httpapi`,
|
||||
конвенция [web-ui](../conventions/web-ui.md).
|
||||
@@ -1,52 +0,0 @@
|
||||
# Полное удаление загрузки из jellybit («единое окно», path 2)
|
||||
|
||||
**Приоритет:** высокий
|
||||
|
||||
Безопасная половина «единого окна» уже в проде: распознавание ручного удаления и
|
||||
пометка рассинхрона (state-reconciliation — `target_missing`/`orphaned`/`deleted`,
|
||||
undo с nlink-гардом, preflight). Осталась вторая половина — **удалять из самого
|
||||
jellybit**, не идя руками в qBittorrent/Jellyfin.
|
||||
|
||||
Основной сценарий одного окна — «досмотрел → освободить место»: удалить и раздачу,
|
||||
и файлы в библиотеке разом. Из-за хардлинков иначе место и не вернуть — файл в
|
||||
`downloads/` (qBittorrent) и наша ссылка в библиотеке указывают на **один инод**,
|
||||
диск освобождается только когда исчезает последняя ссылка. Значит delete обязан
|
||||
снять **обе** стороны.
|
||||
|
||||
## Действие «Удалить» (одна загрузка)
|
||||
|
||||
- Снять живые хардлинки загрузки (переиспользуем undo-механику: `superseded`
|
||||
пропускаем) **+** удалить раздачу с файлами из qBittorrent (новый метод в `qbt`,
|
||||
`deleteTorrents` с `deleteFiles=true`).
|
||||
- **Осознанно обходим** предохранитель последней копии (undo при «источник удалён,
|
||||
цель — последняя копия» отказывает — `worker/review.go:499`; здесь мы наоборот
|
||||
хотим снять последнюю копию).
|
||||
- Обязательное **подтверждение** — это выход за инвариант «источник
|
||||
неприкосновенен», не по случайному клику. Логировать как осознанное удаление
|
||||
источника.
|
||||
|
||||
## undo vs delete (зафиксировать различие)
|
||||
|
||||
- **undo** — «перераспознать»: снимает только наши ссылки, раздачу в qBittorrent
|
||||
сохраняет, данные бережёт (гард включён). Уже готов.
|
||||
- **delete** — «убрать окончательно, освободить место»: снимает наши ссылки И
|
||||
сносит раздачу+файлы, гард выключен, состояние терминальное.
|
||||
|
||||
## Терминальное состояние
|
||||
|
||||
Переиспользуем существующий `deleted` (исход тот же: и источник, и цель сняты) —
|
||||
**без** нового статуса. Инициатора (`user` против reconciliation) пишем в причину/
|
||||
лог перехода. В граф переходов добавить явное пользовательское ребро
|
||||
`done → deleted` (сейчас `deleted` выводит только сверка с реальностью).
|
||||
|
||||
## Опционально (если понадобится)
|
||||
|
||||
Мультивыбор в списке + «удалить выбранное» — тонкая обёртка над тем же действием
|
||||
для редкого случая «снести сериал целиком». Вычисляемую группу-«тайтл» и экран
|
||||
состава **не** делаем — оверинжиниринг ради редкого сценария.
|
||||
|
||||
Оформить как OpenSpec-change (дельта `state-reconciliation`/`review` + метод `qbt`
|
||||
+ ребро графа + action в UI/боте).
|
||||
|
||||
Связано: drafts/logical-title-model.md §5.3/§6.4, ADR-2026-06-13-hardlinks,
|
||||
openspec/specs/state-reconciliation, specs/workflow.md, пакеты qbt, worker, layout.
|
||||
@@ -1,13 +0,0 @@
|
||||
# Проблема второго сезона (сходимость папки сериала)
|
||||
|
||||
**Приоритет:** высокий
|
||||
|
||||
Второй/третий сезон должен ложиться в ТУ ЖЕ папку сериала, а не заводить рядом почти одинаковую. Проблема не в группировке, а в сходимости папки: имя печатается заново из выхода LLM, совпадение provider_id не гарантирует совпадение строки («Fargo» vs «Фарго», год сезона vs год сериала). Отдельная сущность «тайтл» НЕ вводится. Решение — правило сходимости при построении плана: при подтверждённом матче наследовать базу папки (имя+год) от живых file_link загрузок с тем же (provider, provider_id), игнорируя LLM-выход; якоря нет → папка из распознавания, как сейчас.
|
||||
|
||||
Шаги:
|
||||
- lookup живых ссылок по (provider, provider_id) через current recognition
|
||||
- наследование базы папки (имя+год) при построении плана раскладки
|
||||
- рассинхрон (несколько живых папок с одним матчем) → review, не молча
|
||||
- тесты: сходимость, отсутствие якоря (свежая папка), смена провайдера
|
||||
|
||||
Связано: drafts/logical-title-model.md §5.2, specs/recognition.md, specs/jellyfin-layout.md
|
||||
@@ -68,14 +68,45 @@ jellybit — **приложение, а не библиотека**: внешн
|
||||
к загрузке) либо `request_id`, чтобы по нему найти полную ошибку в логах.
|
||||
Пример: «При обработке загрузки произошла ошибка, download_id=12345», а
|
||||
не «произошла ошибка» и не сырой текст;
|
||||
- **маппинг доменной ошибки → статус/сообщение**: `ErrNotFound` → 404
|
||||
«не найдено», валидация/`ErrNotMagnet` → 400 «некорректный источник»,
|
||||
конфликт состояния (`ErrConflict` — операция недопустима в текущем
|
||||
состоянии) → 409 «действие недоступно в текущем состоянии», прочее →
|
||||
500 «внутренняя ошибка».
|
||||
- **маппинг доменной ошибки → статус/сообщение** (в jellybit —
|
||||
`httpapi.classifyErr`, единая точка для REST и веб-UI):
|
||||
|
||||
Граница публичная по умолчанию. Истинно приватный для владельца канал —
|
||||
логи; отдельной «операторской» поверхности с сырыми ошибками не заводим.
|
||||
| Доменная ошибка | Статус | Сообщение |
|
||||
|---|---|---|
|
||||
| `store.ErrNotFound` | 404 | «не найдено» |
|
||||
| `magnet.ErrNotMagnet` / `torrent.ErrNotTorrent` | 400 | «некорректный источник» |
|
||||
| `worker.ErrInvalidInput` (промах ввода команды) | 400 | «некорректный ввод» |
|
||||
| `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» |
|
||||
| `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» |
|
||||
| `worker.ErrConflict` (операция недопустима сейчас) | 409 | «действие недоступно в текущем состоянии» |
|
||||
| прочее | 500 | «внутренняя ошибка» |
|
||||
|
||||
Новую штатную ветвь отказа (конфликт/валидация) заводим sentinel’ом и
|
||||
добавляем сюда — иначе `default` отдаст 500 «внутренняя ошибка» на
|
||||
нормальный конфликт (и логирующая граница спишет его в `ERROR` вместо
|
||||
`DEBUG`, см. [logging.md](logging.md)).
|
||||
|
||||
### Транзиентный ответ vs персистентная диагностика
|
||||
|
||||
У публичной границы две разные поверхности, и правило сырого текста для них
|
||||
разное:
|
||||
|
||||
- **Транзиентный ответ на действие** (тело REST/`?err=`/answer бота по
|
||||
результату команды) — строго нейтральный: маппинг выше, `err.Error()` наружу
|
||||
не идёт, полная ошибка — в логах по `download_id`/`request_id`.
|
||||
- **Персистентная диагностика состояния** — `error_msg` перехода (причина ухода
|
||||
в review/failed: коллизия, рассинхрон, сбой ФС) и `reasons` распознавания,
|
||||
сохранённые в БД и показываемые на экране ревью и в Telegram-карточке. Это
|
||||
**операторская поверхность владельца**: сервис однопользовательский в
|
||||
доверенной LAN (см. [architecture.md](../specs/architecture.md)), эти поля —
|
||||
диагностический контекст для того, кто разбирает задачу. Здесь сырой текст
|
||||
ошибки (пути, фрагмент ответа LLM/qBittorrent) **допустим и полезен** — но:
|
||||
- **секреты запрещены** абсолютно (токены/ключи/пароли/`Authorization`) — так
|
||||
же, как в логах ([logging.md](logging.md), «Безопасность»). Источник
|
||||
error_msg вычищаем на границе клиента (`logging.SanitizeErr` для ошибок
|
||||
транспорта, несущих URL с секретом);
|
||||
- это **не** канал для транзиентных отказов команд — те остаются нейтральными
|
||||
(см. выше).
|
||||
|
||||
## panic
|
||||
|
||||
|
||||
@@ -39,6 +39,13 @@ log.Info(fmt.Sprintf("download %s accepted as movie", id))
|
||||
- `msg` — чистая категория без неймспейс-префикса: `recognition done`, а не
|
||||
`recognize: done`. Подсистему выносим в поле `capability`
|
||||
(`ingest`/`recognition`/`file-layout`/`review`), не в текст.
|
||||
- **Смена состояния загрузки — единая категория `state transition`** с полями
|
||||
`from`/`to`/`code` (какое именно состояние и по какой причине — это данные,
|
||||
не текст). Любой переход (в т.ч. `cancel`/`retry`/`relink`) пишет этот
|
||||
`msg`, чтобы весь жизненный цикл собирался одним фильтром: `jq
|
||||
'select(.msg=="state transition" and .download_id=="…")'`. Физический эффект
|
||||
сверх перехода — отдельная запись своей категории (`layout linked`,
|
||||
`layout reverted`, `review hint added`), не подменяет запись перехода.
|
||||
|
||||
## Уровни
|
||||
|
||||
@@ -141,13 +148,44 @@ log.Error(err.Error())
|
||||
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
|
||||
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя —
|
||||
контекст накапливается в цепочке `%w`.
|
||||
- Логируем ошибку **один раз — на границе доменного слоя** (use-case
|
||||
`Ingest`, стадии воркера), которая определяет исход операции: полем
|
||||
`error`, уровень `ERROR`. В Go логирует этот единый чокпоинт, а не каждый
|
||||
транспорт — так транспорты остаются тонкими.
|
||||
- Логируем ошибку **один раз — на границе доменного слоя**, которая
|
||||
определяет исход операции: полем `error`. В Go логирует этот единый
|
||||
чокпоинт, а не каждый транспорт — так транспорты остаются тонкими. Границы
|
||||
в jellybit:
|
||||
- use-case `Ingest` (приём);
|
||||
- **асинхронные стадии воркера** (поллинг, распознавание, авто-раскладка) —
|
||||
исход стадии, вызванной таймером/циклом;
|
||||
- **публичные команды воркера** (`Apply`/`Refine`/`Cancel`/`Retry`/`Undo`/
|
||||
`Delete`/…), вызываемые транспортами. Исход команды логирует ровно один
|
||||
чокпоинт (`worker.logCmd`, в `defer` при именованном возврате), а не
|
||||
HTTP/web/Telegram — они одну и ту же команду зовут из трёх мест.
|
||||
- Транспорты (HTTP/web/Telegram) переводят возвращённую ошибку в свой ответ
|
||||
(статус, сообщение пользователю) и **не логируют** её повторно — иначе
|
||||
один сбой даёт дубли.
|
||||
- **Уровень доменного отказа — по адресату, а не по месту.** У каждой
|
||||
доменной ошибки ровно один логирующий; уровень выбирает он. На **границе
|
||||
команды** (пользователь инициировал действие и ждёт ответа — `worker.logCmd`):
|
||||
|
||||
| Класс отказа | Кому | Уровень |
|
||||
|---|---|---|
|
||||
| штатный конфликт состояния / некорректный ввод (`ErrConflict`, `ErrNotReady`, `ErrInvalidInput`, `ErrNotFound`, `layout.ErrCollision`) | пользователю (уже получил ответ на поверхности) | `DEBUG` |
|
||||
| нарушенный инвариант хранилища/учёта (не безопасность данных: файлы уже разложены) | команде, «может стать проблемой» | `WARN` |
|
||||
| сбой БД / ФС / недоступность зависимости | команде, в разбор | `ERROR` |
|
||||
|
||||
Тот же класс отказа в **асинхронной стадии** (пользователь не ждёт: авто-
|
||||
раскладка, поллинг) адресован уже команде как деградация автоматики — уровень
|
||||
поднимается. Пример: `layout.ErrCollision` в ручном `Apply` — `DEBUG` (человек
|
||||
видит причину в карточке), а в авто-раскладке — `WARN` («auto-apply failed,
|
||||
left for review»): автоматика не довела задачу, это «может стать проблемой».
|
||||
|
||||
- **Повторяющийся сбой фонового цикла (поллинг/сверка) — `WARN`, не `ERROR`.**
|
||||
Одиночный промах тика (`poll`/`sweep`/`list failed`, недоступный
|
||||
qBittorrent) транзиентен: следующий тик повторит. Тот же класс сбоя внутри
|
||||
синхронной операции (`ingest.Ingest`) — `ERROR`, потому что операция
|
||||
провалилась целиком и повтора нет. То есть уровень задаёт не текст ошибки, а
|
||||
наличие штатного ретрая: тик повторится → `WARN`, разовая операция упала →
|
||||
`ERROR`. (Устойчивый сбой N тиков подряд эскалировать в `ERROR` — на будущее,
|
||||
сейчас не реализовано.)
|
||||
- Телеметрия внешнего вызова (`ext.*`, см. ниже) — отдельная запись о
|
||||
поведении зависимости, не дубль доменной ошибки.
|
||||
- Глушить ошибку без лога — только с однострочным комментарием «почему».
|
||||
@@ -206,6 +244,15 @@ log.Error(err.Error())
|
||||
быть большим) — только на `DEBUG`, с вычисткой секретов и обрезкой по длине.
|
||||
- При сомнении — не логируем значение, логируем факт его наличия
|
||||
(`"has_api_key", true`).
|
||||
- **Ошибка HTTP-транспорта несёт URL — потенциальный носитель секрета.**
|
||||
`*url.Error` (стандартный `net/http`) встраивает полный URL запроса, а
|
||||
секрет может жить прямо в нём: токен Telegram в пути (`…/bot<TOKEN>/…`),
|
||||
`api_key` метабазы в query. Санитизируем на границе клиента **до** лога и
|
||||
обёртки — `logging.SanitizeErr(err)` разворачивает `*url.Error` в
|
||||
первопричину (URL отбрасывается, `errors.Is` на причину сохраняется).
|
||||
Применяется в `ext.*`-обёртке (`ExtCall`), клиентах metadata и tgbot. Общее
|
||||
правило: **секрет не кладём в URL, если у API есть заголовок** — тогда его
|
||||
нет и в ошибке транспорта.
|
||||
|
||||
## Куда пишем и уровень
|
||||
|
||||
|
||||
@@ -248,12 +248,19 @@ qBittorrent, но: v1/v2/гибрид дают разные значения; п
|
||||
|
||||
```
|
||||
1. ULID загрузки + download_infohash (дедуп переезжает). ← фундамент
|
||||
2. правило сходимости папки при плане раскладки. ← «второй сезон»
|
||||
2. правило сходимости папки при плане раскладки. ← «второй сезон» ✓ реализовано
|
||||
3. merge-раскладка (докачивание: доложить недостающее). ← §6.2
|
||||
4. группа «тайтл» в UI (вычисляемая) + удаление целиком (path 2). ← §6.4
|
||||
(state_transition — вставить, когда захочется таймлайн/метрики)
|
||||
```
|
||||
|
||||
> Шаг 2 (правило сходимости папки) реализован — change
|
||||
> `openspec/changes/archive/2026-07-10-series-folder-convergence/`, требования
|
||||
> влиты в `openspec/specs/file-layout/`. Отличие от §5.2 черновика: живость якоря
|
||||
> определяется существованием папки на диске (`os.Lstat`), а не только статусом
|
||||
> ссылки; рассинхрон нескольких живых папок → review; in-app разрешение
|
||||
> рассинхрона осознанно вне scope (ручной фикс на диске).
|
||||
|
||||
Каждый шаг — отдельный OpenSpec change; 1–2 самодостаточны и закрывают главную
|
||||
боль.
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ erDiagram
|
||||
TEXT error_msg "nullable"
|
||||
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
|
||||
TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)"
|
||||
TEXT retried_at "nullable; время последнего ручного retry (RFC 3339 UTC Z), сброс базиса таймаутов (миграция 0010)"
|
||||
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||
TEXT updated_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,11 @@ series/
|
||||
|
||||
- provider-id — на папке сериала.
|
||||
- Сезоны — `Season 01`, файлы — `... SxxEyy`.
|
||||
- **Сходимость папки:** при подтверждённом матче база папки (имя+год) наследуется
|
||||
от живой папки-якоря того же `(provider, provider_id)` (существующей на диске), а
|
||||
не печатается заново из выхода LLM — так второй сезон ложится в ту же папку, что
|
||||
и первый. Несколько разных живых папок одного матча → review. Источник истины —
|
||||
`openspec/specs/file-layout/` («Сходимость базы папки…»).
|
||||
|
||||
## Сопоставление источник → цель
|
||||
|
||||
|
||||
+42
-12
@@ -22,6 +22,7 @@ stateDiagram-v2
|
||||
downloading --> completed: файлы на месте
|
||||
downloading --> stuck: stalledDL дольше stuck_after
|
||||
downloading --> failed: metaDL дольше magnet_timeout (страховка) / error
|
||||
downloading --> failed: источник пропал из qBittorrent (source_gone, после дебаунса)
|
||||
|
||||
completed --> recognizing
|
||||
|
||||
@@ -50,10 +51,11 @@ stateDiagram-v2
|
||||
|
||||
done --> target_missing: сверка — цель удалена
|
||||
done --> orphaned: сверка — источник пропал
|
||||
done --> deleted: Удалить (delete)
|
||||
target_missing --> recognizing: Привязать заново
|
||||
target_missing --> orphaned: источник тоже пропал
|
||||
target_missing --> deleted: источник тоже пропал
|
||||
orphaned --> deleted: цель тоже удалена
|
||||
target_missing --> deleted: Удалить / источник тоже пропал (сверка)
|
||||
orphaned --> deleted: Удалить / цель тоже удалена (сверка)
|
||||
target_missing --> done: healing (цель вернулась)
|
||||
orphaned --> done: healing (источник вернулся)
|
||||
|
||||
@@ -95,7 +97,8 @@ stateDiagram-v2
|
||||
[architecture.md](architecture.md) → «Раскладка файлов».
|
||||
- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin
|
||||
(опц., см. [architecture.md](architecture.md) → «Пересканирование
|
||||
Jellyfin»); доступен **Undo** → `reverted` (убрать созданные ссылки).
|
||||
Jellyfin»); доступен **Undo** → `reverted` (убрать созданные ссылки) и
|
||||
**Удалить** → `deleted` (полное удаление, см. ниже).
|
||||
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
|
||||
(ретраибельна); «Отклонить».
|
||||
- **reverted / cancelled → recognizing** — «Привязать заново»: после
|
||||
@@ -119,6 +122,18 @@ stateDiagram-v2
|
||||
- **deleted** — нет ни источника, ни цели; **терминально**: сверка его
|
||||
больше не переоценивает (см. ниже).
|
||||
|
||||
**Undo vs Удалить (delete).** Это разные пользовательские операции. **Undo**
|
||||
(из `done`) — «перераспознать»: снимает только наши библиотечные ссылки, раздачу
|
||||
в qBittorrent бережёт, гард последней копии включён (не сотрёт единственный
|
||||
файл) → `reverted`. **Удалить** (из `done`, `orphaned`, `target_missing`) —
|
||||
«убрать окончательно, освободить место»: снимает наши ссылки **и** сносит раздачу
|
||||
с файлами из qBittorrent, гард последней копии осознанно выключен (обход
|
||||
инварианта «источник неприкосновенен» — только по подтверждению) →
|
||||
терминальный `deleted`. Идемпотентно к отсутствующей стороне, так что подчищает
|
||||
остатки из любого из трёх состояний. Инициатор в `deleted` различается по
|
||||
`error_code`: пользовательское удаление — `user_delete`, вывод сверкой —
|
||||
`reconcile`. Полные требования — `openspec/specs/state-reconciliation/`.
|
||||
|
||||
Сверка трогает только `done`/`target_missing`/`orphaned` — терминальный
|
||||
`deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/
|
||||
`failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при
|
||||
@@ -157,14 +172,26 @@ SQLite; `worker` периодически сверяет qBittorrent с БД и
|
||||
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/
|
||||
`queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`.
|
||||
- **застряло по таймауту (страховка):** `metaDL`/`forcedMetaDL` дольше
|
||||
`magnet_timeout` → `failed`; `stalledDL` дольше `stuck_after` → `stuck`.
|
||||
`magnet_timeout` — **редкий страховочный предохранитель** (дефолт `24h`), а
|
||||
не рабочий механизм: долгий `metaDL` (медленные трекеры/мало пиров) — это
|
||||
норма, его не убиваем агрессивно. Возраст считаем от времени добавления
|
||||
торрента в qBittorrent (`added_on`), а не от создания задачи (базис
|
||||
переживает retry и усыновление).
|
||||
`magnet_timeout` → `failed`; `stalledDL` **простаивающий** дольше
|
||||
`stuck_after` → `stuck`. `magnet_timeout` — **редкий страховочный
|
||||
предохранитель** (дефолт `24h`), а не рабочий механизм: долгий `metaDL`
|
||||
(медленные трекеры/мало пиров) — это норма, его не убиваем агрессивно. Меры у
|
||||
двух таймаутов **разные**: `magnet_timeout` мерит **возраст** торрента от
|
||||
добавления в qBittorrent (`added_on`, фолбэк `created_at`); `stuck_after`
|
||||
мерит **длительность простоя** — от `last_activity` (последнее движение
|
||||
данных), а не возраст, иначе долго качавшийся торрент, на миг зашедший в
|
||||
`stalledDL`, ложно уходит в `stuck` со «stalled for 5h». Оба базиса
|
||||
приподнимаются до `retried_at` — ручной retry сбрасывает отсчёт, чтобы возврат
|
||||
в `downloading` не ронял задачу снова на ближайшем тике.
|
||||
- **ошибка:** `error`/`missingFiles` → `failed` (`error_code` `qbit_error`) —
|
||||
это настоящий провал, в отличие от таймаута.
|
||||
- **источник пропал:** раздача активной загрузки устойчиво (после дебаунса
|
||||
`source_missing_threshold`, тот же счётчик, что и сверка рассинхрона) исчезла
|
||||
из qBittorrent (удалил пользователь/другой клиент) → `failed` (`error_code`
|
||||
`source_gone`). Иначе `downloading` без раздачи оставался бы вечным зомби,
|
||||
которого никто не двигает (MAJOR-3). В отличие от таймаутов, сверка
|
||||
`source_gone` **не воскрешает** (удаление намеренно) — но задача штатно
|
||||
retriable: `Retry` заново отдаёт сохранённый источник.
|
||||
|
||||
### Уведомление и восстановление
|
||||
|
||||
@@ -179,10 +206,13 @@ SQLite; `worker` периодически сверяет qBittorrent с БД и
|
||||
только источник в qBittorrent ожил и продвинулся за условие падения
|
||||
(получил метаданные → `downloading`; уже готов → `completed`). Пока торрент
|
||||
всё ещё в `metaDL`/`stalledDL`, задача остаётся упавшей (без зацикливания).
|
||||
Настоящие провалы (`qbit_error`) сверкой не воскрешаются.
|
||||
Настоящие провалы (`qbit_error`) и намеренная пропажа источника
|
||||
(`source_gone`) сверкой не воскрешаются — только ручной retry.
|
||||
- Дополнительно доступен **ручной retry** из веб-UI и Telegram (не только
|
||||
REST): возвращает в `downloading`, перецепляясь к живому торренту без
|
||||
повторного `Add`.
|
||||
REST): возвращает в `downloading`, перецепляясь к живому **здоровому** торренту
|
||||
без повторного `Add` (к сломанному — `error`/`missingFiles` — не
|
||||
перецепляемся, повторно отдаём источник) и сбрасывая базис таймаутов
|
||||
(`retried_at`), чтобы задача не упала снова на ближайшем тике.
|
||||
|
||||
Пути файлов берём из API (`save_path` + относительные имена из
|
||||
`/torrents/files`, уже включающие корневую папку торрента), не из
|
||||
|
||||
@@ -25,6 +25,7 @@ func (s stubCommander) Retry(context.Context, string) error { return s.retryErr
|
||||
type actionReviewer struct {
|
||||
stubReviewer
|
||||
undoErr error
|
||||
deleteErr error
|
||||
relinkErr error
|
||||
rerecognizeErr error
|
||||
refineErr error
|
||||
@@ -32,6 +33,7 @@ type actionReviewer struct {
|
||||
}
|
||||
|
||||
func (a actionReviewer) Undo(context.Context, string) error { return a.undoErr }
|
||||
func (a actionReviewer) Delete(context.Context, string) error { return a.deleteErr }
|
||||
func (a actionReviewer) Relink(context.Context, string) error { return a.relinkErr }
|
||||
func (a actionReviewer) Rerecognize(context.Context, string) error { return a.rerecognizeErr }
|
||||
func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error {
|
||||
|
||||
@@ -47,12 +47,18 @@ type downloadDetailView struct {
|
||||
// Живая статистика раздачи (заполняется из снимка воркера).
|
||||
Seeding seedingView
|
||||
|
||||
// Nameable — доступно ручное обновление имени: есть распознанное название,
|
||||
// которое можно перелить в display_name/ярлык раздачи. Гейтится наличием
|
||||
// распознавания (в т.ч. на done/orphaned), НЕ состоянием ревью.
|
||||
Nameable bool
|
||||
|
||||
// Действия по состоянию (как на главной).
|
||||
Terminal bool
|
||||
Reviewable bool
|
||||
Undoable bool
|
||||
Relinkable bool
|
||||
Retriable bool
|
||||
Deletable bool // полное удаление доступно (done/orphaned/target_missing)
|
||||
}
|
||||
|
||||
// detailTitle — заголовок страницы просмотра: имя раздачи (display_name) →
|
||||
@@ -80,7 +86,7 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "задача не найдена", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.deps.Logger.Error("download detail data", "id", id, "error", err)
|
||||
s.deps.Logger.Error("download detail data failed", "download_id", id, "error", err)
|
||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -111,6 +117,8 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
||||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
||||
d.State == store.StateTargetMissing,
|
||||
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
||||
Deletable: d.State == store.StateDone || d.State == store.StateOrphaned ||
|
||||
d.State == store.StateTargetMissing,
|
||||
}
|
||||
// Дата добавления рядом с шапкой (source_added_at → фолбэк created_at,
|
||||
// как в порядке и карточках списка); неразбираемое время просто опускаем.
|
||||
@@ -129,6 +137,9 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
||||
view.Season = seasonSummary(rd.Plan)
|
||||
}
|
||||
view.Year = rd.Plan.Year
|
||||
// Ручное обновление имени доступно, когда есть распознанное название,
|
||||
// которое можно перелить (иначе FormatTitleYear даст пусто → no-op).
|
||||
view.Nameable = rd.Plan.Title != ""
|
||||
switch rd.Provider {
|
||||
case "", "none":
|
||||
view.NoBase = rd.Provider == "none"
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/torrent"
|
||||
@@ -133,10 +134,12 @@ func NewRouter(d Deps) (http.Handler, error) {
|
||||
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
|
||||
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
|
||||
r.Post("/ui/downloads/{id}/source", s.handleAddSource)
|
||||
r.Post("/ui/downloads/{id}/refresh-name", s.handleRefreshName)
|
||||
r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
|
||||
r.Post("/ui/downloads/{id}/defer", s.handleDefer)
|
||||
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
|
||||
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
|
||||
r.Post("/ui/downloads/{id}/delete", s.handleDelete)
|
||||
|
||||
// REST API.
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
@@ -746,20 +749,29 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
|
||||
// classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное
|
||||
// человекочитаемое сообщение публичного канала (без сырого err.Error() и
|
||||
// деталей реализации): ErrNotFound → 404, валидация источника
|
||||
// (magnet.ErrNotMagnet) → 400, недокачанный источник (worker.ErrNotReady) и
|
||||
// конфликт состояния (worker.ErrConflict) → 409, прочее → 500. Полная ошибка
|
||||
// уже в логах на доменной границе — наружу отдаём только сообщение +
|
||||
// корреляционный ключ.
|
||||
// деталей реализации): ErrNotFound → 404; валидация источника
|
||||
// (magnet.ErrNotMagnet) и некорректный ввод команды (worker.ErrInvalidInput) →
|
||||
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели
|
||||
// (layout.ErrCollision) и конфликт состояния (worker.ErrConflict) → 409; прочее
|
||||
// → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только
|
||||
// сообщение + корреляционный ключ.
|
||||
func classifyErr(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
return http.StatusNotFound, "не найдено"
|
||||
case errors.Is(err, magnet.ErrNotMagnet), errors.Is(err, torrent.ErrNotTorrent):
|
||||
return http.StatusBadRequest, "некорректный источник"
|
||||
case errors.Is(err, worker.ErrInvalidInput):
|
||||
// Промах пользователя (пустая подсказка, неизвестный тип/провайдер, …),
|
||||
// не сбой сервера.
|
||||
return http.StatusBadRequest, "некорректный ввод"
|
||||
case errors.Is(err, worker.ErrNotReady):
|
||||
// Источник ещё качается — actionable причина, показываем конкретно.
|
||||
return http.StatusConflict, "торрент ещё качается, дождитесь докачки"
|
||||
case errors.Is(err, layout.ErrCollision):
|
||||
// Целевой путь уже занят: задача штатно ушла в review с причиной —
|
||||
// это не сбой, а требующий разбора конфликт.
|
||||
return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью"
|
||||
case errors.Is(err, worker.ErrConflict):
|
||||
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
||||
return http.StatusConflict, "действие недоступно в текущем состоянии"
|
||||
|
||||
@@ -247,6 +247,41 @@ func TestAPICommandNotReady(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPICommandInvalidInput(t *testing.T) {
|
||||
// Промах пользователя (worker.ErrInvalidInput) → 400, не 500.
|
||||
cmd := &fakeCommander{err: fmt.Errorf("set type: invalid type %q: %w", "foo", worker.ErrInvalidInput)}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPICommandCollision(t *testing.T) {
|
||||
// Коллизия цели (layout.ErrCollision) → 409 (штатно ушло в review), не 500.
|
||||
cmd := &fakeCommander{err: fmt.Errorf("apply: %w", layout.ErrCollision)}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", resp.StatusCode)
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
if msg, _ := got["error"].(string); !strings.Contains(msg, "уже существует") {
|
||||
t.Errorf("error = %q, want содержащее «уже существует»", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexRenders(t *testing.T) {
|
||||
reader := &fakeReader{list: []store.Download{
|
||||
{ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
|
||||
@@ -444,19 +479,21 @@ func TestDownloadTorrentInfoBlock(t *testing.T) {
|
||||
// --- Ревью ---
|
||||
|
||||
type fakeReviewer struct {
|
||||
data *worker.ReviewData
|
||||
applyErr error
|
||||
refined map[string]string
|
||||
ignored map[string]string
|
||||
chosen map[string]string
|
||||
providerSet map[string]string
|
||||
sourceAdded map[string]string
|
||||
applied []string
|
||||
deferred []string
|
||||
undone []string
|
||||
relinked []string
|
||||
rerecognized []string
|
||||
cleared []string
|
||||
data *worker.ReviewData
|
||||
applyErr error
|
||||
refined map[string]string
|
||||
ignored map[string]string
|
||||
chosen map[string]string
|
||||
providerSet map[string]string
|
||||
sourceAdded map[string]string
|
||||
applied []string
|
||||
deferred []string
|
||||
undone []string
|
||||
deleted []string
|
||||
relinked []string
|
||||
rerecognized []string
|
||||
cleared []string
|
||||
nameRefreshed []string
|
||||
}
|
||||
|
||||
func (f *fakeReviewer) ReviewData(_ context.Context, _ string) (*worker.ReviewData, error) {
|
||||
@@ -491,6 +528,10 @@ func (f *fakeReviewer) Undo(_ context.Context, id string) error {
|
||||
f.undone = append(f.undone, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Delete(_ context.Context, id string) error {
|
||||
f.deleted = append(f.deleted, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Relink(_ context.Context, id string) error {
|
||||
f.relinked = append(f.relinked, id)
|
||||
return nil
|
||||
@@ -524,6 +565,10 @@ func (f *fakeReviewer) ClearProvider(_ context.Context, id string) error {
|
||||
f.cleared = append(f.cleared, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) RefreshDisplayName(_ context.Context, id string) error {
|
||||
f.nameRefreshed = append(f.nameRefreshed, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func seriesReviewData() *worker.ReviewData {
|
||||
s, e := 2, 1
|
||||
@@ -677,6 +722,87 @@ func TestAddManualSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshNameNonHTMXRedirectsToDownload(t *testing.T) {
|
||||
rv := &fakeReviewer{data: seriesReviewData()}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/refresh-name", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if len(rv.nameRefreshed) != 1 || rv.nameRefreshed[0] != tid {
|
||||
t.Errorf("RefreshDisplayName вызван = %v", rv.nameRefreshed)
|
||||
}
|
||||
// Без htmx — PRG на страницу загрузки (увидеть новое имя здесь же), не на список.
|
||||
if loc := resp.Header.Get("Location"); loc != "/download/"+tid {
|
||||
t.Errorf("Location = %q, want /download/%s", loc, tid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshNameHTMXSwapsMain(t *testing.T) {
|
||||
rv := &fakeReviewer{data: seriesReviewData()}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/refresh-name", nil)
|
||||
req.Header.Set("HX-Request", "true")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), `id="download-main"`) {
|
||||
t.Error("htmx-ответ не содержит фрагмент download-main")
|
||||
}
|
||||
if len(rv.nameRefreshed) != 1 {
|
||||
t.Errorf("RefreshDisplayName вызван = %v", rv.nameRefreshed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPageShowsRefreshNameButtonOnDone(t *testing.T) {
|
||||
// Кнопка гейтится наличием распознавания, не состоянием ревью: на терминальном
|
||||
// done (есть план) она всё равно доступна.
|
||||
rd := seriesReviewData()
|
||||
rd.Download.State = store.StateDone
|
||||
rv := &fakeReviewer{data: rd}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/download/" + tid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "/refresh-name") {
|
||||
t.Error("кнопка «Обновить имя» не показана на done при наличии распознавания")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPageHidesRefreshNameWithoutRecognition(t *testing.T) {
|
||||
rv := &fakeReviewer{data: &worker.ReviewData{
|
||||
Download: store.Download{ID: tid, State: store.StateDownloading, SourceRef: "magnet:?xt=urn:btih:abc"},
|
||||
}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/download/" + tid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if strings.Contains(string(body), "/refresh-name") {
|
||||
t.Error("кнопка «Обновить имя» показана без распознавания")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddManualSource_RejectsBadURL(t *testing.T) {
|
||||
rv := &fakeReviewer{data: seriesReviewData()}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
|
||||
@@ -49,6 +49,7 @@ func (stubReviewer) SetType(context.Context, string, string) error
|
||||
func (stubReviewer) IgnoreFile(context.Context, string, string) error { return nil }
|
||||
func (stubReviewer) Defer(context.Context, string) error { return nil }
|
||||
func (stubReviewer) Undo(context.Context, string) error { return nil }
|
||||
func (stubReviewer) Delete(context.Context, string) error { return nil }
|
||||
func (stubReviewer) Relink(context.Context, string) error { return nil }
|
||||
func (stubReviewer) Rerecognize(context.Context, string) error { return nil }
|
||||
func (stubReviewer) ChooseCandidate(context.Context, string, string) error { return nil }
|
||||
@@ -56,7 +57,8 @@ func (stubReviewer) SetProviderID(context.Context, string, string, string) error
|
||||
func (stubReviewer) AddManualSource(context.Context, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
func (stubReviewer) ClearProvider(context.Context, string) error { return nil }
|
||||
func (stubReviewer) ClearProvider(context.Context, string) error { return nil }
|
||||
func (stubReviewer) RefreshDisplayName(context.Context, string) error { return nil }
|
||||
|
||||
// stubLive — заглушка источника живой телеметрии.
|
||||
type stubLive struct{ m map[string]worker.Live }
|
||||
|
||||
@@ -21,12 +21,14 @@ type Reviewer interface {
|
||||
IgnoreFile(ctx context.Context, id string, src string) error
|
||||
Defer(ctx context.Context, id string) error
|
||||
Undo(ctx context.Context, id string) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
Relink(ctx context.Context, id string) error
|
||||
Rerecognize(ctx context.Context, id string) error
|
||||
ChooseCandidate(ctx context.Context, id, candidateID string) error
|
||||
SetProviderID(ctx context.Context, id string, provider, providerID string) error
|
||||
AddManualSource(ctx context.Context, id, provider, providerID string) error
|
||||
ClearProvider(ctx context.Context, id string) error
|
||||
RefreshDisplayName(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// --- Представление страницы ревью ---
|
||||
@@ -86,7 +88,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "задача не найдена", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.deps.Logger.Error("review data", "id", id, "error", err)
|
||||
s.deps.Logger.Error("review data failed", "download_id", id, "error", err)
|
||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -228,6 +230,29 @@ func (s *server) handleAddSource(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleRefreshName переливает распознанное каноническое имя в display_name и в
|
||||
// ярлык раздачи (ручная кнопка на странице загрузки). Свопит главную область
|
||||
// (#download-main) с обновлённым заголовком; без htmx — PRG на страницу загрузки
|
||||
// (в отличие от surfaceAction, уводящего на список: смысл действия — увидеть
|
||||
// новое имя здесь же). Ошибку на htmx-пути показываем 200 + фрагментом.
|
||||
func (s *server) handleRefreshName(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
redirectErr(w, r, "некорректный id")
|
||||
return
|
||||
}
|
||||
actionErr := s.deps.Reviewer.RefreshDisplayName(r.Context(), id)
|
||||
if !isHTMX(r) {
|
||||
if actionErr != nil {
|
||||
redirectErr(w, r, userErr(r, actionErr, id))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/download/"+id, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.renderDownloadFragment(w, r, id, actionErr)
|
||||
}
|
||||
|
||||
var errInvalidCandidate = errors.New("некорректный id кандидата")
|
||||
|
||||
var errManualSource = errors.New("не удалось разобрать id или URL записи (для TVDB — числовой id)")
|
||||
@@ -332,6 +357,19 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) {
|
||||
s.surfaceAction(w, r, id, s.deps.Reviewer.Undo(r.Context(), id))
|
||||
}
|
||||
|
||||
// handleDelete — полное удаление загрузки (снять хардлинки + снести раздачу с
|
||||
// файлами из qBittorrent → deleted). Осознанное необратимое действие: транспорт
|
||||
// подтверждает его перед POST (hx-confirm + отдельная danger-секция). Ошибку
|
||||
// qBittorrent surfaceAction покажет как отказ (не тихий успех).
|
||||
func (s *server) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
redirectErr(w, r, "некорректный id")
|
||||
return
|
||||
}
|
||||
s.surfaceAction(w, r, id, s.deps.Reviewer.Delete(r.Context(), id))
|
||||
}
|
||||
|
||||
// handleRelink повторно привязывает откатанную задачу: перезапускает
|
||||
// распознавание, задача пройдёт recognizing → review для подтверждения.
|
||||
func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -369,7 +407,7 @@ func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(co
|
||||
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
if err != nil {
|
||||
s.deps.Logger.Error("review data", "id", id, "error", err)
|
||||
s.deps.Logger.Error("review data failed", "download_id", id, "error", err)
|
||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -429,7 +467,7 @@ func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn fu
|
||||
// и рендерим свежий партиал блока.
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
if err != nil {
|
||||
s.deps.Logger.Error("review data", "id", id, "error", err)
|
||||
s.deps.Logger.Error("review data failed", "download_id", id, "error", err)
|
||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ type Store interface {
|
||||
// AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на
|
||||
// быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive.
|
||||
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
||||
// UpgradeCatchedMagnetToTorrent при дедупе входящих байтов `.torrent` на
|
||||
// пойманную (`catched`) magnet-задачу сохраняет байты и меняет source_type
|
||||
// на torrent (атомарно). No-op, если задача уже добавлена/не magnet. Чинит
|
||||
// magnet закрытого трекера, который иначе застрянет в metaDL.
|
||||
UpgradeCatchedMagnetToTorrent(ctx context.Context, downloadID string, torrentBlob []byte) (bool, error)
|
||||
}
|
||||
|
||||
// Service — реализация быстрого приёма.
|
||||
@@ -72,6 +77,9 @@ type Result struct {
|
||||
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
src, err := s.parse(req)
|
||||
if err != nil {
|
||||
// Невалидный источник — норма (адресат не команда, а пользователь, и он
|
||||
// получит отказ на транспорте): DEBUG, чтобы не шуметь в аудите.
|
||||
s.log.Debug("ingest source rejected", "capability", capIngest, "error", err)
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
@@ -83,6 +91,8 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
// CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника:
|
||||
// гибридный несёт и v1, и v2.
|
||||
if existing, err := s.store.FindActiveByInfohash(ctx, src.infohashes...); err != nil {
|
||||
// Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR.
|
||||
log.Error("ingest failed", "stage", "lookup-active", "error", err)
|
||||
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
||||
} else if existing != nil {
|
||||
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||||
@@ -104,19 +114,16 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
// транзакции лишь на ветке создания.
|
||||
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, src.infohashes, src.torrentBlob)
|
||||
if err != nil {
|
||||
// Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR.
|
||||
log.Error("ingest failed", "stage", "create-download", "error", err)
|
||||
return Result{}, fmt.Errorf("ingest: create download: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
// Гонка с параллельным приёмом/discover: активная задача появилась
|
||||
// после быстрого чека — присоединяемся к ней (хеши донёс сам
|
||||
// CreateDownloadIfNoActive).
|
||||
// CreateDownloadIfNoActive; existing уже с подгруженными хешами).
|
||||
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||||
return Result{
|
||||
DownloadID: existing.ID,
|
||||
Infohashes: src.infohashes,
|
||||
State: existing.State,
|
||||
Deduplicated: true,
|
||||
}, nil
|
||||
return s.attached(ctx, src, existing), nil
|
||||
}
|
||||
|
||||
log.Info("download catched", "download_id", d.ID)
|
||||
@@ -208,6 +215,17 @@ func (s *Service) attached(ctx context.Context, src parsedSource, existing *stor
|
||||
logctx.FromOr(ctx, s.log).Warn("ingest top-up infohashes failed", "error", err)
|
||||
}
|
||||
}
|
||||
// F6: входящее — байты `.torrent`, а активная задача поймана как magnet и
|
||||
// ещё не отдана в qBittorrent (catched) → сохраняем байты и переключаем
|
||||
// источник на torrent, чтобы воркер добавил раздачу файлом (magnet
|
||||
// закрытого трекера иначе застрянет в metaDL). Best-effort: не валит приём.
|
||||
if src.sourceType == store.SourceTorrent && len(src.torrentBlob) > 0 {
|
||||
if upgraded, err := s.store.UpgradeCatchedMagnetToTorrent(ctx, existing.ID, src.torrentBlob); err != nil {
|
||||
logctx.FromOr(ctx, s.log).Warn("ingest torrent upgrade failed", "error", err)
|
||||
} else if upgraded {
|
||||
logctx.FromOr(ctx, s.log).Info("catched magnet upgraded to torrent", "download_id", existing.ID)
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
DownloadID: existing.ID,
|
||||
Infohashes: src.infohashes,
|
||||
|
||||
@@ -16,11 +16,14 @@ const sampleMagnet = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331
|
||||
const sampleInfohash = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
|
||||
type fakeStore struct {
|
||||
active *store.Download
|
||||
created []store.Download
|
||||
hashes [][]string
|
||||
blobs [][]byte
|
||||
toppedUp []string
|
||||
active *store.Download
|
||||
created []store.Download
|
||||
hashes [][]string
|
||||
blobs [][]byte
|
||||
toppedUp []string
|
||||
upgradeID string // downloadID последнего вызова UpgradeCatchedMagnetToTorrent
|
||||
upgradeBlob []byte // байты, переданные в апгрейд
|
||||
upgradeUp bool // что вернуть из UpgradeCatchedMagnetToTorrent
|
||||
}
|
||||
|
||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
|
||||
@@ -44,6 +47,12 @@ func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpgradeCatchedMagnetToTorrent(_ context.Context, id string, blob []byte) (bool, error) {
|
||||
f.upgradeID = id
|
||||
f.upgradeBlob = blob
|
||||
return f.upgradeUp, nil
|
||||
}
|
||||
|
||||
func newService(st Store) *Service {
|
||||
return New(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
|
||||
@@ -83,6 +83,45 @@ func TestIngestTorrentDedupNoBlob(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// F6: дедуп .torrent на пойманную (catched) magnet-задачу вызывает апгрейд —
|
||||
// сохранение байтов и смену источника (magnet закрытого трекера иначе застрянет
|
||||
// в metaDL).
|
||||
func TestIngestTorrentUpgradesCatchedMagnet(t *testing.T) {
|
||||
data, hash := buildTorrent(t, "Dune", "http://t/ann")
|
||||
existing := &store.Download{
|
||||
ID: "cm",
|
||||
State: store.StateCatched,
|
||||
SourceType: store.SourceMagnet,
|
||||
Infohashes: []store.Infohash{{Infohash: hash, Kind: store.HashV1}},
|
||||
}
|
||||
fs := &fakeStore{active: existing, upgradeUp: true}
|
||||
res, err := newService(fs).Ingest(context.Background(), Request{TorrentData: data})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if !res.Deduplicated || res.DownloadID != "cm" {
|
||||
t.Errorf("ожидался дедуп на cm, res = %+v", res)
|
||||
}
|
||||
if fs.upgradeID != "cm" {
|
||||
t.Errorf("апгрейд не вызван для существующей задачи (upgradeID=%q)", fs.upgradeID)
|
||||
}
|
||||
if !bytes.Equal(fs.upgradeBlob, data) {
|
||||
t.Errorf("в апгрейд переданы не те байты")
|
||||
}
|
||||
}
|
||||
|
||||
// Дедуп magnet-ссылки (не .torrent) апгрейд не вызывает — нечего сохранять.
|
||||
func TestIngestMagnetDedupNoUpgrade(t *testing.T) {
|
||||
existing := &store.Download{ID: "cm", State: store.StateCatched, SourceType: store.SourceMagnet}
|
||||
fs := &fakeStore{active: existing}
|
||||
if _, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet}); err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if fs.upgradeID != "" {
|
||||
t.Errorf("апгрейд не должен вызываться для magnet-дедупа")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestTorrentTooLarge(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
big := make([]byte, MaxTorrentSize+1)
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
@@ -68,7 +69,13 @@ type Plan struct {
|
||||
Title string
|
||||
Year int
|
||||
ProviderTag string // напр. "tmdbid-693134"; пусто — без тега
|
||||
Files []PlanFile
|
||||
// FolderBase — унаследованная от живого якоря база имени ("Название (Год)").
|
||||
// Пусто → база печатается из Title/Year (первая загрузка тайтла). Непусто →
|
||||
// перекрывает Title/Year и идёт и в папку, и в имена файлов (правило
|
||||
// сходимости папки, см. file-layout spec). Provider-тег добавляется отдельно
|
||||
// из ProviderTag.
|
||||
FolderBase string
|
||||
Files []PlanFile
|
||||
}
|
||||
|
||||
// Link — посчитанная пара источник → цель.
|
||||
@@ -136,7 +143,7 @@ func (l *Layouter) BuildLinks(p Plan) ([]Link, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base, err := titleYear(p.Title, p.Year)
|
||||
base, err := planBase(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -209,6 +216,36 @@ func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Ki
|
||||
}
|
||||
}
|
||||
|
||||
// TitleFolder разбирает целевой путь dst живой ссылки в папку тайтла и её базу
|
||||
// имени для правила сходимости (см. file-layout spec). Возвращает абсолютный
|
||||
// путь папки тайтла (первый сегмент под корнем библиотеки типа t), её базу
|
||||
// (снят хвостовой provider-тег) и ok. ok=false, если dst не под корнем нужной
|
||||
// библиотеки или база пуста — вызывающий трактует как «нет якоря». Существование
|
||||
// папки на диске здесь НЕ проверяется (это делает worker через os.Lstat).
|
||||
func (l *Layouter) TitleFolder(t MediaType, dst string) (dir, base string, ok bool) {
|
||||
root, err := l.root(t)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
dst = filepath.Clean(dst)
|
||||
if !underRoot(root, dst) {
|
||||
return "", "", false
|
||||
}
|
||||
rel, err := filepath.Rel(root, dst)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
first, _, _ := strings.Cut(rel, string(filepath.Separator))
|
||||
if first == "" || first == "." {
|
||||
return "", "", false
|
||||
}
|
||||
base = folderBase(first)
|
||||
if base == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return filepath.Join(root, first), base, true
|
||||
}
|
||||
|
||||
// LinkStatus — исход создания одной ссылки.
|
||||
type LinkStatus string
|
||||
|
||||
@@ -434,6 +471,34 @@ func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) {
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// Remove снимает целевые ссылки БЕЗ гарда последней копии — в отличие от Undo,
|
||||
// который отказывается стирать единственную копию данных. Служит осознанному
|
||||
// удалению загрузки («освободить место»): снимает последнюю библиотечную ссылку,
|
||||
// даже если источник уже пропал. Как и Undo, трогает только пути строго под
|
||||
// библиотеками (источник недосягаем) и идемпотентен к отсутствующей цели.
|
||||
// Возвращает число удалённых ссылок.
|
||||
func (l *Layouter) Remove(ctx context.Context, links []Link) (int, error) {
|
||||
log := logctx.FromOr(ctx, l.log)
|
||||
removed := 0
|
||||
for _, ln := range links {
|
||||
root, err := undoRoot(l, ln.Dst)
|
||||
if err != nil {
|
||||
return removed, err
|
||||
}
|
||||
if err := os.Remove(ln.Dst); err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue // цели уже нет — снимать нечего (идемпотентно)
|
||||
}
|
||||
log.Error("layout remove failed", "dst", ln.Dst, "error", err)
|
||||
return removed, fmt.Errorf("layout: remove %q: %w", ln.Dst, err)
|
||||
}
|
||||
removed++
|
||||
log.Debug("layout link removed (delete)", "dst", ln.Dst)
|
||||
pruneEmptyDirs(filepath.Dir(ln.Dst), root)
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// undoRoot возвращает корень библиотеки, под которым лежит dst, либо ошибку,
|
||||
// если путь не под movies/series (откат трогает только библиотеку).
|
||||
func undoRoot(l *Layouter, dst string) (string, error) {
|
||||
|
||||
@@ -98,6 +98,78 @@ func TestBuildLinks_Series(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLinks_FolderBaseOverridesSeries(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
// LLM дал «Fargo» 2017, но живой якорь — «Фарго (2014)». FolderBase должна
|
||||
// перекрыть Title/Year и в папке, И в имени файла.
|
||||
plan := Plan{
|
||||
Type: Series, Title: "Fargo", Year: 2017, ProviderTag: "tvdbid-269613",
|
||||
FolderBase: "Фарго (2014)",
|
||||
Files: []PlanFile{
|
||||
{Src: f.srcFile(t, "s/e1.mkv", "1"), Role: RoleEpisode, Season: intp(2), Episode: intp(1)},
|
||||
},
|
||||
}
|
||||
links, err := f.l.BuildLinks(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLinks: %v", err)
|
||||
}
|
||||
want := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 02", "Фарго (2014) S02E01.mkv")
|
||||
if links[0].Dst != want {
|
||||
t.Errorf("ep = %q, want %q", links[0].Dst, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLinks_FolderBaseOverridesMovie(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
plan := Plan{
|
||||
Type: Movie, Title: "Dune", Year: 2021, ProviderTag: "tmdbid-693134",
|
||||
FolderBase: "Дюна Часть вторая (2024)",
|
||||
Files: []PlanFile{{Src: f.srcFile(t, "m/f.mkv", "1"), Role: RoleMain}},
|
||||
}
|
||||
links, err := f.l.BuildLinks(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLinks: %v", err)
|
||||
}
|
||||
want := filepath.Join(f.movies, "Дюна Часть вторая (2024) [tmdbid-693134]", "Дюна Часть вторая (2024).mkv")
|
||||
if links[0].Dst != want {
|
||||
t.Errorf("main = %q, want %q", links[0].Dst, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitleFolder(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
epDst := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 01", "Фарго (2014) S01E01.mkv")
|
||||
dir, base, ok := f.l.TitleFolder(Series, epDst)
|
||||
if !ok {
|
||||
t.Fatal("want ok for path under series root")
|
||||
}
|
||||
if wantDir := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]"); dir != wantDir {
|
||||
t.Errorf("dir = %q, want %q", dir, wantDir)
|
||||
}
|
||||
if base != "Фарго (2014)" {
|
||||
t.Errorf("base = %q, want %q", base, "Фарго (2014)")
|
||||
}
|
||||
|
||||
// Фильм: папка тайтла — первый сегмент, база без тега.
|
||||
mvDst := filepath.Join(f.movies, "Dune (2024) [tmdbid-1]", "Dune (2024).mkv")
|
||||
if dir, base, ok := f.l.TitleFolder(Movie, mvDst); !ok ||
|
||||
dir != filepath.Join(f.movies, "Dune (2024) [tmdbid-1]") || base != "Dune (2024)" {
|
||||
t.Errorf("movie: dir=%q base=%q ok=%v", dir, base, ok)
|
||||
}
|
||||
|
||||
// Путь не под корнем нужной библиотеки → not ok.
|
||||
if _, _, ok := f.l.TitleFolder(Series, mvDst); ok {
|
||||
t.Error("movie path must not resolve under series root")
|
||||
}
|
||||
if _, _, ok := f.l.TitleFolder(Movie, "/etc/passwd"); ok {
|
||||
t.Error("path outside library must be rejected")
|
||||
}
|
||||
// Сам корень (нет сегмента папки) → not ok.
|
||||
if _, _, ok := f.l.TitleFolder(Movie, f.movies); ok {
|
||||
t.Error("root itself has no title folder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLinks_SeriesEpisodeWithoutNumber(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
plan := Plan{
|
||||
|
||||
@@ -40,6 +40,20 @@ func titleYear(title string, year int) (string, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// planBase выбирает базу имени плана: унаследованная от живого якоря
|
||||
// FolderBase (правило сходимости папки) перекрывает Title/Year. База в любом
|
||||
// случае санитизируется (FolderBase пришла с диска — прогон идемпотентен).
|
||||
func planBase(p Plan) (string, error) {
|
||||
if p.FolderBase != "" {
|
||||
b := sanitizeComponent(p.FolderBase)
|
||||
if b == "" {
|
||||
return "", fmt.Errorf("layout: empty folder base after sanitization (%q)", p.FolderBase)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
return titleYear(p.Title, p.Year)
|
||||
}
|
||||
|
||||
// folderName добавляет provider-тег к базе: "Название (Год) [tmdbid-123]".
|
||||
func folderName(base, providerTag string) string {
|
||||
tag := sanitizeComponent(providerTag)
|
||||
@@ -49,6 +63,19 @@ func folderName(base, providerTag string) string {
|
||||
return fmt.Sprintf("%s [%s]", base, tag)
|
||||
}
|
||||
|
||||
// folderBase восстанавливает базу имени ("Название (Год)") из имени папки
|
||||
// тайтла, снимая хвостовой provider-тег " [...]" (любой, а не только текущий:
|
||||
// у переоценённого якоря тег мог остаться старым). Тег без пробела перед "["
|
||||
// или незакрытый — не трогаем. Результат санитизируется; пустой → "" (вызывающий
|
||||
// трактует как «нет якоря»).
|
||||
func folderBase(folder string) string {
|
||||
base := folder
|
||||
if i := strings.LastIndex(folder, " ["); i >= 0 && strings.HasSuffix(folder, "]") {
|
||||
base = folder[:i]
|
||||
}
|
||||
return sanitizeComponent(base)
|
||||
}
|
||||
|
||||
// seasonFolder — "Season 00" (спецвыпуски) / "Season 01" / ...
|
||||
func seasonFolder(season int) string {
|
||||
return fmt.Sprintf("Season %02d", season)
|
||||
|
||||
@@ -47,6 +47,25 @@ func TestFolderName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderBase(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"Фарго (2014) [tvdbid-269613]", "Фарго (2014)"}, // текущий тег
|
||||
{"Fargo (2017) [tmdbid-123]", "Fargo (2017)"}, // чужой/старый тег снимается так же
|
||||
{"No Tag (2020)", "No Tag (2020)"}, // без тега — как есть
|
||||
{"Bare Name", "Bare Name"}, // без года и тега
|
||||
{"Weird [not a tag", "Weird [not a tag"}, // незакрытый — не трогаем
|
||||
{"[tvdbid-1]", "[tvdbid-1]"}, // нет " [" с пробелом — не тег
|
||||
{"Movie (2020) [edition-Director's Cut]", "Movie (2020)"}, // снимается любой хвостовой [...]
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := folderBase(tt.in); got != tt.want {
|
||||
t.Errorf("folderBase(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpisodeStem(t *testing.T) {
|
||||
if got := episodeStem("Fargo (2015)", 2, 1, 0); got != "Fargo (2015) S02E01" {
|
||||
t.Errorf("got %q", got)
|
||||
|
||||
@@ -54,12 +54,15 @@ func (c ExtCall) SuccessDebug(log *slog.Logger, extra ...any) {
|
||||
log.Debug("external call", c.attrs(extra...)...)
|
||||
}
|
||||
|
||||
// Retry логирует неудачную попытку, после которой будет повтор (WARN).
|
||||
// Retry логирует неудачную попытку, после которой будет повтор (WARN). Ошибка
|
||||
// санитизируется (SanitizeErr): транспортный сбой несёт URL с секретом
|
||||
// (api_key TMDB в query, токен в пути) — в лог он попасть не должен.
|
||||
func (c ExtCall) Retry(log *slog.Logger, err error, extra ...any) {
|
||||
log.Warn("external call retry", c.attrs(append([]any{"error", err}, extra...)...)...)
|
||||
log.Warn("external call retry", c.attrs(append([]any{"error", SanitizeErr(err)}, extra...)...)...)
|
||||
}
|
||||
|
||||
// Failure логирует окончательную неудачу вызова / недоступность сервиса (ERROR).
|
||||
// Ошибка санитизируется (SanitizeErr) — см. Retry.
|
||||
func (c ExtCall) Failure(log *slog.Logger, err error, extra ...any) {
|
||||
log.Error("external call failed", c.attrs(append([]any{"error", err}, extra...)...)...)
|
||||
log.Error("external call failed", c.attrs(append([]any{"error", SanitizeErr(err)}, extra...)...)...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// SanitizeErr убирает из ошибки HTTP-транспорта URL запроса и возвращает только
|
||||
// первопричину. Ошибки клиента net/http — `*url.Error`, чей текст встраивает
|
||||
// полный URL, а URL может нести секрет: токен бота Telegram в пути
|
||||
// (`…/bot<TOKEN>/<method>`) или api_key TMDB в query
|
||||
// (`…/search/movie?api_key=<SECRET>&…`). Логировать или оборачивать такую ошибку
|
||||
// как есть нельзя — секрет утечёт в логи (инвариант «секреты не в логи», см.
|
||||
// logging.md, раздел «Безопасность»).
|
||||
//
|
||||
// Логическая операция при этом не теряется: она пишется отдельным полем
|
||||
// (`ext.operation`) на границе клиента. Не `*url.Error` — возвращаем как есть.
|
||||
func SanitizeErr(err error) error {
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return ue.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// SanitizeErr обязана убрать URL (носитель секрета) из *url.Error, оставив
|
||||
// первопричину — это единый предохранитель от утечки токена/ключа в логи.
|
||||
func TestSanitizeErr(t *testing.T) {
|
||||
cause := errors.New("dial tcp 1.2.3.4:443: connect: connection refused")
|
||||
|
||||
t.Run("url error with token in path", func(t *testing.T) {
|
||||
ue := &url.Error{
|
||||
Op: "Get",
|
||||
URL: "https://api.telegram.org/botSECRET123:AAtoken/getUpdates",
|
||||
Err: cause,
|
||||
}
|
||||
got := SanitizeErr(ue)
|
||||
if strings.Contains(got.Error(), "SECRET123") || strings.Contains(got.Error(), "AAtoken") {
|
||||
t.Fatalf("токен утёк: %v", got)
|
||||
}
|
||||
if !errors.Is(got, cause) {
|
||||
t.Fatalf("первопричина потеряна: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("url error with api_key in query", func(t *testing.T) {
|
||||
ue := &url.Error{
|
||||
Op: "Get",
|
||||
URL: "https://api.themoviedb.org/3/search/movie?api_key=DEADBEEFSECRET&query=dune",
|
||||
Err: cause,
|
||||
}
|
||||
if strings.Contains(SanitizeErr(ue).Error(), "DEADBEEFSECRET") {
|
||||
t.Fatalf("api_key утёк: %v", SanitizeErr(ue))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrapped url error is unwrapped via errors.As", func(t *testing.T) {
|
||||
wrapped := fmt.Errorf("metadata: request: %w", &url.Error{
|
||||
Op: "Get", URL: "https://x/api_key=SECRET", Err: cause,
|
||||
})
|
||||
if strings.Contains(SanitizeErr(wrapped).Error(), "SECRET") {
|
||||
t.Fatalf("секрет утёк из обёрнутой ошибки: %v", SanitizeErr(wrapped))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-url error passes through unchanged", func(t *testing.T) {
|
||||
plain := errors.New("plain domain error")
|
||||
if !errors.Is(SanitizeErr(plain), plain) {
|
||||
t.Fatalf("обычная ошибка изменена: %v", SanitizeErr(plain))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil stays nil", func(t *testing.T) {
|
||||
if SanitizeErr(nil) != nil {
|
||||
t.Fatal("nil должен оставаться nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -79,6 +79,10 @@ func doJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, ope
|
||||
call := logging.ExtCall{Service: service, Operation: operation, Start: time.Now()}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
// Транспортный сбой несёт *url.Error с полным URL, а у TMDB api_key —
|
||||
// query-параметр: санитизируем до логирования И до обёртки, чтобы ключ
|
||||
// не утёк ни в лог, ни вверх по цепочке %w.
|
||||
err = logging.SanitizeErr(err)
|
||||
call.Failure(log, err)
|
||||
return fmt.Errorf("metadata: request: %w", err)
|
||||
}
|
||||
|
||||
@@ -69,6 +69,12 @@ func New(provider llm.Provider, attempts int, logger *slog.Logger) *Namer {
|
||||
// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает
|
||||
// "" если имя получить не удалось (тогда вызывающий не задаёт rename).
|
||||
func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string {
|
||||
// Нет ни контекста, ни подсказки — выводить имя не из чего. LLM на пустом
|
||||
// входе способен лишь галлюцинировать (наблюдалось «Unknown»), поэтому его не
|
||||
// зовём: имя считается не выведенным, вызывающий добавит загрузку без rename.
|
||||
if strings.TrimSpace(contextText) == "" && strings.TrimSpace(hint) == "" {
|
||||
return ""
|
||||
}
|
||||
if n.provider != nil {
|
||||
if ex, ok := n.extractViaLLM(ctx, contextText, hint); ok {
|
||||
if name := render(ex); name != "" {
|
||||
@@ -79,6 +85,22 @@ func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string
|
||||
return fallbackName(contextText, hint)
|
||||
}
|
||||
|
||||
// FormatTitleYear собирает детерминированный ярлык «Title (Year)» из уже
|
||||
// распознанного названия — без сети и LLM (перелив канонического имени после
|
||||
// распознавания). Год опционален; имя очищается от управляющих символов и
|
||||
// обрезается по длине. Пустой title → пустая строка. В отличие от render это
|
||||
// НЕ формат add-шага (там ещё режиссёр и сезон): у перелива их нет.
|
||||
func FormatTitleYear(title string, year int) string {
|
||||
title = sanitize(title)
|
||||
if title == "" {
|
||||
return ""
|
||||
}
|
||||
if year > 0 {
|
||||
title += " (" + strconv.Itoa(year) + ")"
|
||||
}
|
||||
return truncate(title, maxNameLen)
|
||||
}
|
||||
|
||||
// render собирает из структуры короткий ярлык:
|
||||
// - movie: "Title (Director, Year)" — режиссёр и год опциональны;
|
||||
// - series: то же + ". Сезон N", если сезон задан.
|
||||
|
||||
@@ -49,6 +49,53 @@ func TestDeriveNameViaLLM(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameEmptyInputSkipsLLM(t *testing.T) {
|
||||
// Голый magnet без dn/контекста: выводить имя не из чего — LLM не зовём.
|
||||
fp := &fakeProvider{responses: []string{
|
||||
`{"type":"movie","title":"Unknown"}`, // как раз мусор, который был бы записан
|
||||
}}
|
||||
got := New(fp, 3, testLogger()).DeriveName(context.Background(), " ", " ")
|
||||
if got != "" {
|
||||
t.Errorf("DeriveName() = %q, want empty", got)
|
||||
}
|
||||
if fp.calls != 0 {
|
||||
t.Errorf("вызовов LLM = %d, want 0 (пустой вход не зовёт модель)", fp.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameHintPresentSkipsGuard(t *testing.T) {
|
||||
// Контекст пуст, но есть подсказка (dn) — гард пустого входа НЕ срабатывает:
|
||||
// имя выводится фолбеком из подсказки. LLM здесь не зовётся (он работает
|
||||
// только по контексту, а его нет) — это прежнее поведение, гард его не меняет.
|
||||
fp := &fakeProvider{responses: []string{`{"type":"movie","title":"x"}`}}
|
||||
got := New(fp, 3, testLogger()).DeriveName(context.Background(), "", "Dune.Part.Two.2024")
|
||||
if got == "" {
|
||||
t.Error("при наличии подсказки имя не должно быть пустым (гард не должен глотать hint)")
|
||||
}
|
||||
if fp.calls != 0 {
|
||||
t.Errorf("вызовов LLM = %d, want 0 (LLM выводит по контексту, которого нет)", fp.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTitleYear(t *testing.T) {
|
||||
cases := []struct {
|
||||
title string
|
||||
year int
|
||||
want string
|
||||
}{
|
||||
{"Harold and the Purple Crayon", 2024, "Harold and the Purple Crayon (2024)"},
|
||||
{"Брат", 0, "Брат"},
|
||||
{" Fargo\n", 2015, "Fargo (2015)"},
|
||||
{"", 2024, ""},
|
||||
{" ", 0, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := FormatTitleYear(c.title, c.year); got != c.want {
|
||||
t.Errorf("FormatTitleYear(%q, %d) = %q, want %q", c.title, c.year, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameRussianTitle(t *testing.T) {
|
||||
fp := &fakeProvider{responses: []string{
|
||||
`{"type":"movie","title":"Брат","original_title":"Brat","year":1997,"is_russian":true}`,
|
||||
|
||||
+98
-2
@@ -60,8 +60,13 @@ type Torrent struct {
|
||||
AmountLeft int64 `json:"amount_left"`
|
||||
TotalSize int64 `json:"total_size"` // полный размер раздачи, байт
|
||||
AddedOn int64 `json:"added_on"`
|
||||
InfohashV1 string `json:"infohash_v1"`
|
||||
InfohashV2 string `json:"infohash_v2"`
|
||||
// LastActivity — Unix-время последнего движения данных по торренту (скачан/
|
||||
// отдан кусок). Базис измерения простоя для stuck_after: простой = now −
|
||||
// last_activity (а не возраст от added_on), поэтому долго качавшийся торрент,
|
||||
// на миг зашедший в stalledDL, не помечается «зависшим».
|
||||
LastActivity int64 `json:"last_activity"`
|
||||
InfohashV1 string `json:"infohash_v1"`
|
||||
InfohashV2 string `json:"infohash_v2"`
|
||||
|
||||
// Живая телеметрия (для снимка воркера и веб-UI).
|
||||
Dlspeed int64 `json:"dlspeed"` // скорость загрузки, байт/с
|
||||
@@ -251,6 +256,97 @@ func (c *Client) Add(ctx context.Context, ar AddRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete удаляет раздачу(и) из qBittorrent. deleteFiles=true сносит и файлы на
|
||||
// диске (освобождая место — библиотечный хардлинк jellybit к тому же иноду
|
||||
// снимается отдельно). Идемпотентно: неизвестный хеш qBittorrent просто
|
||||
// игнорирует, ошибкой это не считаем. jellybit сам файлы под downloads не
|
||||
// трогает — их удаляет qBittorrent по этому вызову.
|
||||
func (c *Client) Delete(ctx context.Context, hashes []string, deleteFiles bool) error {
|
||||
nonEmpty := hashes[:0:0]
|
||||
for _, h := range hashes {
|
||||
if h = strings.TrimSpace(h); h != "" {
|
||||
nonEmpty = append(nonEmpty, h)
|
||||
}
|
||||
}
|
||||
if len(nonEmpty) == 0 {
|
||||
return fmt.Errorf("qbittorrent delete: no hashes")
|
||||
}
|
||||
form := url.Values{
|
||||
"hashes": {strings.Join(nonEmpty, "|")},
|
||||
"deleteFiles": {strconv.FormatBool(deleteFiles)},
|
||||
}
|
||||
body := form.Encode()
|
||||
|
||||
log := logctx.FromOr(ctx, c.log)
|
||||
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/delete", Start: time.Now()}
|
||||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.endpoint("/api/v2/torrents/delete"), strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", c.base.String())
|
||||
return req, nil
|
||||
})
|
||||
if err != nil {
|
||||
call.Failure(log, err)
|
||||
return fmt.Errorf("qbittorrent delete: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
call.Status = resp.StatusCode
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||
err := fmt.Errorf("qbittorrent delete: status %d body %q",
|
||||
resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
call.Failure(log, err)
|
||||
return err
|
||||
}
|
||||
call.Success(log, "hashes", len(nonEmpty), "delete_files", deleteFiles)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameTorrent задаёт отображаемое имя уже добавленной раздачи (в отличие от
|
||||
// параметра rename при Add, действующего только в момент добавления). hash —
|
||||
// ключ раздачи в qBittorrent (Torrent.Hash), а не сырой infohash загрузки:
|
||||
// вызывающий резолвит его листингом. Косметика: имя раздачи не влияет на файлы.
|
||||
func (c *Client) RenameTorrent(ctx context.Context, hash, name string) error {
|
||||
hash = strings.TrimSpace(hash)
|
||||
if hash == "" {
|
||||
return fmt.Errorf("qbittorrent rename: empty hash")
|
||||
}
|
||||
form := url.Values{"hash": {hash}, "name": {name}}
|
||||
body := form.Encode()
|
||||
|
||||
log := logctx.FromOr(ctx, c.log)
|
||||
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/rename", Start: time.Now()}
|
||||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.endpoint("/api/v2/torrents/rename"), strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", c.base.String())
|
||||
return req, nil
|
||||
})
|
||||
if err != nil {
|
||||
call.Failure(log, err)
|
||||
return fmt.Errorf("qbittorrent rename: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
call.Status = resp.StatusCode
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||
err := fmt.Errorf("qbittorrent rename: status %d body %q",
|
||||
resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
call.Failure(log, err)
|
||||
return err
|
||||
}
|
||||
call.Success(log)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Torrents возвращает задачи указанной категории (пустая — все).
|
||||
func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) {
|
||||
log := logctx.FromOr(ctx, c.log)
|
||||
|
||||
@@ -114,6 +114,83 @@ func TestAddSendsRename(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSendsHashesAndDeleteFiles(t *testing.T) {
|
||||
var gotHashes, gotDeleteFiles string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/torrents/delete", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
gotHashes = r.PostForm.Get("hashes")
|
||||
gotDeleteFiles = r.PostForm.Get("deleteFiles")
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
c := newClient(t, srv.URL)
|
||||
err := c.Delete(context.Background(), []string{"aaa", "", "bbb"}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if gotHashes != "aaa|bbb" { // пустые хеши отфильтрованы, склейка через |
|
||||
t.Errorf("hashes = %q, want %q", gotHashes, "aaa|bbb")
|
||||
}
|
||||
if gotDeleteFiles != "true" {
|
||||
t.Errorf("deleteFiles = %q, want true", gotDeleteFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameTorrentSendsHashAndName(t *testing.T) {
|
||||
var gotHash, gotName string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/torrents/rename", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
gotHash = r.PostForm.Get("hash")
|
||||
gotName = r.PostForm.Get("name")
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
c := newClient(t, srv.URL)
|
||||
if err := c.RenameTorrent(context.Background(), "aaa", "Harold and the Purple Crayon (2024)"); err != nil {
|
||||
t.Fatalf("RenameTorrent: %v", err)
|
||||
}
|
||||
if gotHash != "aaa" {
|
||||
t.Errorf("hash = %q, want aaa", gotHash)
|
||||
}
|
||||
if gotName != "Harold and the Purple Crayon (2024)" {
|
||||
t.Errorf("name = %q", gotName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameTorrentEmptyHashIsError(t *testing.T) {
|
||||
c := newClient(t, "http://unused")
|
||||
if err := c.RenameTorrent(context.Background(), " ", "x"); err == nil {
|
||||
t.Fatal("RenameTorrent with empty hash must error before any request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameTorrentNon200IsError(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/torrents/rename", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
c := newClient(t, srv.URL)
|
||||
if err := c.RenameTorrent(context.Background(), "aaa", "x"); err == nil {
|
||||
t.Fatal("RenameTorrent must error on non-200")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteNoHashesIsError(t *testing.T) {
|
||||
c := newClient(t, "http://unused")
|
||||
if err := c.Delete(context.Background(), []string{"", " "}, true); err == nil {
|
||||
t.Fatal("Delete with no hashes must error before any request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPerformsLazyLogin(t *testing.T) {
|
||||
srv := fakeQBittorrent(t, "[]")
|
||||
c := newClient(t, srv.URL)
|
||||
|
||||
@@ -22,6 +22,14 @@ const maxCandidates = 8
|
||||
// (original_title → title → provider_hint, см. searchKeys): базы индексированы
|
||||
// прежде всего по оригинальным названиям. Останавливаемся, как только очередной
|
||||
// ключ дал единичный сильный матч (ранний стоп — дешевле по обращениям к базе).
|
||||
//
|
||||
// Проходов сверки два: pass 1 — с годом плана (дешёвое сужение при верном годе),
|
||||
// pass 2 — fallback без года, только если год известен и pass 1 не подтвердил
|
||||
// матч. Exact-year фильтр запроса строже гейта strongMatches (год ±1): безгодовой
|
||||
// проход восстанавливает off-by-one авто-матчи (запись, которую точный фильтр
|
||||
// отсёк, а гейт принял бы) и пополняет кандидатов для review при бо́льших ошибках
|
||||
// года. Гейт при этом не меняется (год ±1 по plan.Year), инвариант авто-матча не
|
||||
// двигается. Кандидаты копятся через оба прохода (дедуп по provider:id, потолок).
|
||||
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
|
||||
if len(r.providers) == 0 {
|
||||
return nil, nil
|
||||
@@ -32,43 +40,65 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
|
||||
}
|
||||
|
||||
matchTitles := normSet(plan.Title, plan.OriginalTitle)
|
||||
keys := searchKeys(plan)
|
||||
|
||||
// Год запроса по проходам: сначала год плана, затем 0 (без года) как fallback.
|
||||
// При неизвестном годе второй проход был бы идентичен первому — не делаем.
|
||||
queryYears := []int{plan.Year}
|
||||
if plan.Year > 0 {
|
||||
queryYears = append(queryYears, 0)
|
||||
}
|
||||
|
||||
var match *Match
|
||||
var candidates []metadata.Candidate
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, key := range searchKeys(plan) {
|
||||
for _, p := range r.providers {
|
||||
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: plan.Year})
|
||||
if err != nil {
|
||||
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
|
||||
// доменное решение «пропускаем провайдера, пробуем следующий».
|
||||
logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
|
||||
for _, c := range cands {
|
||||
ck := c.Provider + ":" + c.ID
|
||||
if seen[ck] || len(candidates) >= maxCandidates {
|
||||
for _, qYear := range queryYears {
|
||||
// Безгодовой fallback-проход (есть только при известном годе плана).
|
||||
// В нём требуем известный год кандидата: off-by-one даёт авто-матч, а
|
||||
// запись с неизвестным годом — только кандидат в review (год подтвердить
|
||||
// нечем, авто было бы недо-подтверждённым). В pass 1 leniency yearMatches
|
||||
// к unknown-году сохраняется как прежде.
|
||||
fallback := qYear == 0 && plan.Year > 0
|
||||
for _, key := range keys {
|
||||
for _, p := range r.providers {
|
||||
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: qYear})
|
||||
if err != nil {
|
||||
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
|
||||
// доменное решение «пропускаем провайдера, пробуем следующий».
|
||||
logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name())
|
||||
continue
|
||||
}
|
||||
seen[ck] = true
|
||||
candidates = append(candidates, c)
|
||||
}
|
||||
|
||||
// Единичный сильный матч ищем у первого подходящего провайдера.
|
||||
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
|
||||
for _, c := range cands {
|
||||
ck := c.Provider + ":" + c.ID
|
||||
if seen[ck] || len(candidates) >= maxCandidates {
|
||||
continue
|
||||
}
|
||||
seen[ck] = true
|
||||
candidates = append(candidates, c)
|
||||
}
|
||||
|
||||
// Единичный сильный матч ищем у первого подходящего провайдера.
|
||||
// Гейт по plan.Year (не qYear): безгодовой проход расширяет только
|
||||
// выдачу запроса, но требует год кандидата ±1 (и известный — в
|
||||
// fallback), поэтому условие подтверждения не ослабляется.
|
||||
if match != nil {
|
||||
continue
|
||||
}
|
||||
strong := strongMatches(cands, plan.Year, matchTitles, fallback)
|
||||
if len(strong) != 1 {
|
||||
continue
|
||||
}
|
||||
match = r.buildMatch(ctx, p, strong[0], mt)
|
||||
}
|
||||
if match != nil {
|
||||
continue
|
||||
break
|
||||
}
|
||||
strong := strongMatches(cands, plan.Year, matchTitles)
|
||||
if len(strong) != 1 {
|
||||
continue
|
||||
}
|
||||
match = r.buildMatch(ctx, p, strong[0], mt)
|
||||
}
|
||||
if match != nil {
|
||||
break
|
||||
break // pass 1 подтвердил матч — безгодовой проход не нужен
|
||||
}
|
||||
}
|
||||
return match, candidates
|
||||
@@ -129,11 +159,15 @@ func CandidateTag(c metadata.Candidate) (provider, id string) {
|
||||
|
||||
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
|
||||
// названий плана (после нормализации) и год бьётся (±1 год), дедуплицируя
|
||||
// по id.
|
||||
func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool) []metadata.Candidate {
|
||||
// по id. requireKnownYear (безгодовой fallback-проход) дополнительно отсекает
|
||||
// кандидатов с неизвестным годом: авто-матч там требует подтверждённого года.
|
||||
func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool, requireKnownYear bool) []metadata.Candidate {
|
||||
seen := map[string]bool{}
|
||||
var out []metadata.Candidate
|
||||
for _, c := range cands {
|
||||
if requireKnownYear && c.Year == 0 {
|
||||
continue
|
||||
}
|
||||
if !yearMatches(year, c.Year) {
|
||||
continue
|
||||
}
|
||||
@@ -175,11 +209,14 @@ func normSet(titles ...string) map[string]bool {
|
||||
|
||||
// normalize приводит название к сравнимому виду: нижний регистр, только
|
||||
// буквы/цифры (юникод), одиночные пробелы. Букву ё сводим к е (частое
|
||||
// расхождение написания: «Тёмный» vs «Темный»).
|
||||
// расхождение написания: «Тёмный» vs «Темный»). Перед этим сворачиваем
|
||||
// кирилло-латинские homoglyph-двойники (foldHomoglyphs) — defense-in-depth на
|
||||
// случай двойников со стороны кандидата базы: гейт сильного матча должен быть
|
||||
// устойчив к ним независимо от санитайзинга плана.
|
||||
func normalize(s string) string {
|
||||
var b strings.Builder
|
||||
prevSpace := false
|
||||
for _, r := range strings.ToLower(s) {
|
||||
for _, r := range strings.ToLower(foldHomoglyphs(s)) {
|
||||
if r == 'ё' {
|
||||
r = 'е'
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type fakeProvider struct {
|
||||
byTitle map[string][]metadata.Candidate // если задано — результат зависит от запроса
|
||||
counts map[int]int
|
||||
searchErr error
|
||||
exactYear bool // имитирует жёсткий year-фильтр базы: q.Year>0 отсекает по точному году
|
||||
searched int
|
||||
queries []string // строки запросов в порядке вызова
|
||||
}
|
||||
@@ -31,10 +32,20 @@ func (f *fakeProvider) Search(_ context.Context, q metadata.Query) ([]metadata.C
|
||||
if f.searchErr != nil {
|
||||
return nil, f.searchErr
|
||||
}
|
||||
res := f.candidates
|
||||
if f.byTitle != nil {
|
||||
return f.byTitle[q.Title], nil
|
||||
res = f.byTitle[q.Title]
|
||||
}
|
||||
return f.candidates, nil
|
||||
if f.exactYear && q.Year > 0 {
|
||||
var filtered []metadata.Candidate
|
||||
for _, c := range res {
|
||||
if c.Year == q.Year {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
func (f *fakeProvider) SeasonEpisodeCounts(_ context.Context, _ string) (map[int]int, error) {
|
||||
return f.counts, nil
|
||||
@@ -264,6 +275,105 @@ func TestMatchMetadata_Disabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_YearlessFallbackOffByOne(t *testing.T) {
|
||||
// Год плана off-by-one: exact-year фильтр запроса отсекает запись в pass 1,
|
||||
// безгодовой pass 2 её возвращает, гейт ±1 принимает → авто-матч.
|
||||
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
|
||||
{Provider: "tmdb", ID: "42", Title: "Harold and the Purple Crayon", Year: 2024},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "Harold and the Purple Crayon", Year: 2023})
|
||||
if m == nil || m.ProviderID != "42" {
|
||||
t.Fatalf("off-by-one год: ожидался матч через безгодовой проход, got %+v", m)
|
||||
}
|
||||
if p.searched != 2 {
|
||||
t.Errorf("searched = %d, want 2 (pass1 с годом + pass2 без года)", p.searched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_YearlessFallbackBigGapReviewOnly(t *testing.T) {
|
||||
// Год расходится больше чем на 1: pass 2 вернёт запись, но гейт по году её
|
||||
// отклонит — подтверждённого матча нет, кандидат уходит в review.
|
||||
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
|
||||
{Provider: "tmdb", ID: "7", Title: "X", Year: 2005},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m, cands := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "X", Year: 2010})
|
||||
if m != nil {
|
||||
t.Errorf("расхождение года >1: авто-матч не ожидался, got %+v", m)
|
||||
}
|
||||
if len(cands) != 1 || cands[0].ID != "7" {
|
||||
t.Errorf("кандидат должен собраться для review: %+v", cands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_YearlessUnknownYearReviewOnly(t *testing.T) {
|
||||
// Год плана известен, но у записи в базе год неизвестен: pass 1 (с годом) её
|
||||
// не находит, pass 2 (без года) находит, но в fallback требуется известный
|
||||
// год → авто-матча нет, кандидат уходит в review.
|
||||
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
|
||||
{Provider: "tmdb", ID: "9", Title: "X", Year: 0},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m, cands := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "X", Year: 2020})
|
||||
if m != nil {
|
||||
t.Errorf("год кандидата неизвестен: авто-матч не ожидался, got %+v", m)
|
||||
}
|
||||
if len(cands) != 1 || cands[0].ID != "9" {
|
||||
t.Errorf("кандидат должен собраться для review: %+v", cands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_NoSecondPassWhenPass1Matches(t *testing.T) {
|
||||
// pass 1 подтвердил матч — безгодовой проход не выполняется.
|
||||
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
|
||||
{Provider: "tmdb", ID: "1", Title: "X", Year: 2000},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "X", Year: 2000})
|
||||
if m == nil {
|
||||
t.Fatal("ожидался матч в pass 1")
|
||||
}
|
||||
if p.searched != 1 {
|
||||
t.Errorf("searched = %d, want 1 (второго прохода быть не должно)", p.searched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_NoSecondPassWhenYearUnknown(t *testing.T) {
|
||||
// Год неизвестен (0): второй проход был бы идентичен первому — не делаем.
|
||||
p := &fakeProvider{candidates: []metadata.Candidate{
|
||||
{Provider: "tmdb", ID: "1", Title: "Y", Year: 2000},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
// Название кандидата совпадает, но нет матча по названию плана — гейт не пройдёт;
|
||||
// проверяем именно число запросов.
|
||||
r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "Совсем другое"})
|
||||
if p.searched != 1 {
|
||||
t.Errorf("searched = %d, want 1 (год неизвестен → одного прохода достаточно)", p.searched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_YearlessAmbiguousNoMatch(t *testing.T) {
|
||||
// Несколько кандидатов из безгодового прохода, проходящих гейт → не подтверждён.
|
||||
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
|
||||
{Provider: "tmdb", ID: "1", Title: "Twin", Year: 2001},
|
||||
{Provider: "tmdb", ID: "2", Title: "Twin", Year: 2001},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m, cands := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "Twin", Year: 2000})
|
||||
if m != nil {
|
||||
t.Errorf("неоднозначность из pass 2: матч не ожидался, got %+v", m)
|
||||
}
|
||||
if len(cands) != 2 {
|
||||
t.Errorf("оба кандидата должны собраться для review: %+v", cands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"The Matrix": "the matrix",
|
||||
@@ -280,6 +390,17 @@ func TestNormalize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize_FoldsHomoglyph(t *testing.T) {
|
||||
// «Harold» с кириллической буквой-двойником 'а' (U+0430) в первом слове
|
||||
// нормализуется к тому же виду, что и чистая латиница (defense-in-depth на
|
||||
// стороне кандидата базы).
|
||||
dirty := "Hаrold and the Purple Crayon" // 'а' — кириллица
|
||||
clean := "Harold and the Purple Crayon"
|
||||
if normalize(dirty) != normalize(clean) {
|
||||
t.Errorf("normalize(%q)=%q != normalize(%q)=%q", dirty, normalize(dirty), clean, normalize(clean))
|
||||
}
|
||||
}
|
||||
|
||||
// Сквозной авто: LLM-план + матч в базе + чистая валидация → Decision.Auto.
|
||||
func TestRecognize_AutoWithMatch(t *testing.T) {
|
||||
in := Input{Name: "The.Matrix.1999", Files: []File{{Path: "m/film.mkv", Size: 1}}}
|
||||
|
||||
@@ -212,7 +212,7 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
||||
}
|
||||
raw = resp.Content
|
||||
|
||||
plan, parseErr = parsePlan(raw, in)
|
||||
plan, parseErr = parsePlan(raw, in, log)
|
||||
if parseErr == nil {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package recognize
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// homoglyphPairs — курируемая таблица визуально неотличимых кирилло-латинских
|
||||
// пар (двойников). Реальная боль — русскоклавиатурные двойники в англоязычных
|
||||
// названиях (кейс «Hаrold» с кир. `а`). Таблица используется в обе стороны:
|
||||
// направление свёртки выбирает foldHomoglyphs по доминирующему скрипту токена.
|
||||
// Единственный источник правды и для санитайзинга плана, и для нормализации в
|
||||
// гейте матча (normalize).
|
||||
var homoglyphPairs = []struct{ cyr, lat rune }{
|
||||
// строчные
|
||||
{'а', 'a'}, {'е', 'e'}, {'о', 'o'}, {'р', 'p'}, {'с', 'c'},
|
||||
{'у', 'y'}, {'х', 'x'}, {'к', 'k'},
|
||||
// заглавные
|
||||
{'А', 'A'}, {'В', 'B'}, {'Е', 'E'}, {'К', 'K'}, {'М', 'M'},
|
||||
{'Н', 'H'}, {'О', 'O'}, {'Р', 'P'}, {'С', 'C'}, {'Т', 'T'},
|
||||
{'Х', 'X'},
|
||||
}
|
||||
|
||||
var (
|
||||
cyr2lat = map[rune]rune{}
|
||||
lat2cyr = map[rune]rune{}
|
||||
)
|
||||
|
||||
func init() {
|
||||
for _, p := range homoglyphPairs {
|
||||
cyr2lat[p.cyr] = p.lat
|
||||
lat2cyr[p.lat] = p.cyr
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM:
|
||||
// (1) убирает управляющие и zero-width символы; (2) сводит пробелы к одиночным и
|
||||
// обрезает края; (3) сворачивает homoglyph-двойники. Порядок важен: strip делаем
|
||||
// до collapse, чтобы удаление zero-width не оставляло сдвоенных пробелов.
|
||||
func sanitizeTitle(s string) string {
|
||||
s = stripControl(s)
|
||||
s = collapseSpaces(s)
|
||||
s = foldHomoglyphs(s)
|
||||
return s
|
||||
}
|
||||
|
||||
// sanitizePlan применяет санитайзинг к человекочитаемым полям плана. files[].src
|
||||
// НЕ трогаем: они обязаны байт-в-байт совпадать с реальными файлами торрента, и
|
||||
// homoglyph там — настоящий mismatch (отклоняется валидацией, уходит в review),
|
||||
// а не повод «чинить» путь. Логируем на Debug, когда значение реально изменилось
|
||||
// (названия не относятся к секретам).
|
||||
func sanitizePlan(p *Plan, log *slog.Logger) {
|
||||
p.Title = sanitizeField(p.Title, "title", log)
|
||||
p.OriginalTitle = sanitizeField(p.OriginalTitle, "original_title", log)
|
||||
p.ProviderHint = sanitizeField(p.ProviderHint, "provider_hint", log)
|
||||
}
|
||||
|
||||
func sanitizeField(v, field string, log *slog.Logger) string {
|
||||
clean := sanitizeTitle(v)
|
||||
if clean != v && log != nil {
|
||||
log.Debug("recognition plan field sanitized", "field", field, "before", v, "after", clean)
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
// stripControl удаляет format-символы (zero-width, BOM, soft-hyphen — категория
|
||||
// Cf) и управляющие C0/C1, кроме пробельных (\t\n\r и пр. остаются — их сведёт
|
||||
// collapseSpaces).
|
||||
func stripControl(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case unicode.Is(unicode.Cf, r):
|
||||
return -1
|
||||
case unicode.IsControl(r) && !unicode.IsSpace(r):
|
||||
return -1
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, s)
|
||||
}
|
||||
|
||||
// collapseSpaces сводит последовательности пробельных к одиночному пробелу и
|
||||
// обрезает края.
|
||||
func collapseSpaces(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
pendingSpace := false
|
||||
for _, r := range s {
|
||||
if unicode.IsSpace(r) {
|
||||
pendingSpace = true
|
||||
continue
|
||||
}
|
||||
if pendingSpace && b.Len() > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
pendingSpace = false
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// foldHomoglyphs сворачивает кирилло-латинские homoglyph-двойники потокенно.
|
||||
// Токен (максимальная последовательность букв) из одного скрипта не трогаем —
|
||||
// билингвальность реальна, честная кириллица неприкосновенна. В смешанном токене
|
||||
// определяем доминирующий скрипт по числу буквенных рун и заменяем
|
||||
// буквы-меньшинство их двойниками из доминирующего скрипта; при равенстве
|
||||
// скриптов токен не меняем. Не-буквенные символы (пробелы, цифры, пунктуация)
|
||||
// разделяют токены и копируются как есть.
|
||||
func foldHomoglyphs(s string) string {
|
||||
runes := []rune(s)
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for i := 0; i < len(runes); {
|
||||
if !unicode.IsLetter(runes[i]) {
|
||||
b.WriteRune(runes[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
j := i
|
||||
for j < len(runes) && unicode.IsLetter(runes[j]) {
|
||||
j++
|
||||
}
|
||||
b.WriteString(foldToken(runes[i:j]))
|
||||
i = j
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func foldToken(tok []rune) string {
|
||||
var cyr, lat int
|
||||
for _, r := range tok {
|
||||
switch {
|
||||
case unicode.Is(unicode.Cyrillic, r):
|
||||
cyr++
|
||||
case unicode.Is(unicode.Latin, r):
|
||||
lat++
|
||||
}
|
||||
}
|
||||
if cyr == 0 || lat == 0 {
|
||||
return string(tok) // одно-скриптовый токен — не трогаем
|
||||
}
|
||||
var m map[rune]rune
|
||||
switch {
|
||||
case lat > cyr:
|
||||
m = cyr2lat // доминирует латиница — сворачиваем кириллические двойники
|
||||
case cyr > lat:
|
||||
m = lat2cyr
|
||||
default:
|
||||
return string(tok) // нет доминирующего скрипта — не трогаем
|
||||
}
|
||||
out := make([]rune, len(tok))
|
||||
for i, r := range tok {
|
||||
if repl, ok := m[r]; ok {
|
||||
out[i] = repl
|
||||
} else {
|
||||
out[i] = r
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package recognize
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFoldHomoglyphs(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
// Latin-доминантный токен: кириллический двойник 'а' (U+0430) → 'a'.
|
||||
"Hаrold and the Purple Crayon": "Harold and the Purple Crayon",
|
||||
// Честная кириллица — не трогаем.
|
||||
"Тёмный рыцарь": "Тёмный рыцарь",
|
||||
// Честная латиница — не трогаем.
|
||||
"The Dark Knight": "The Dark Knight",
|
||||
// Cyrillic-доминантный токен (М р з) с латинскими 'o' (U+006F) → 'о'.
|
||||
"Мoрoз": "Мороз",
|
||||
// Равенство скриптов в токене (кир. О U+041E + лат. k) — не трогаем.
|
||||
"Оk": "Оk",
|
||||
// Двуязычные, но одно-скриптовые токены — каждый нетронут.
|
||||
"Fargo Фарго": "Fargo Фарго",
|
||||
// Буква-меньшинство без двойника в таблице (кир. 'б' U+0431) остаётся.
|
||||
"Harбld": "Harбld",
|
||||
// Пустая строка.
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := foldHomoglyphs(in); got != want {
|
||||
t.Errorf("foldHomoglyphs(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeTitle(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
// Zero-width (U+200B) внутри слова удаляется, слово склеивается.
|
||||
"a\u200bb": "ab",
|
||||
// Zero-width + двойник + лишние пробелы → strip + fold + collapse + trim.
|
||||
" Hаrold\u200b Crayon ": "Harold Crayon",
|
||||
// Табы/переводы строк как пробелы, сведены к одиночным.
|
||||
"Harold\tand the": "Harold and the",
|
||||
// Управляющий символ (BEL U+0007) удаляется.
|
||||
"Harold\u0007": "Harold",
|
||||
// BOM (U+FEFF) удаляется.
|
||||
"\ufeffFargo": "Fargo",
|
||||
// Уже чистое — без изменений.
|
||||
"The Matrix": "The Matrix",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeTitle(in); got != want {
|
||||
t.Errorf("sanitizeTitle(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePlan_FieldsCleanedSrcUntouched(t *testing.T) {
|
||||
// src намеренно содержит кириллический двойник — sanitizePlan его не трогает.
|
||||
dirtySrc := "Hаrold.mkv"
|
||||
p := Plan{
|
||||
Title: "Hаrold",
|
||||
OriginalTitle: " spaced ",
|
||||
ProviderHint: "clean\u200b",
|
||||
Files: []PlanFile{{Src: dirtySrc, Role: RoleMain}},
|
||||
}
|
||||
sanitizePlan(&p, nil)
|
||||
|
||||
if p.Title != "Harold" {
|
||||
t.Errorf("Title = %q, want %q", p.Title, "Harold")
|
||||
}
|
||||
if p.OriginalTitle != "spaced" {
|
||||
t.Errorf("OriginalTitle = %q, want %q", p.OriginalTitle, "spaced")
|
||||
}
|
||||
if p.ProviderHint != "clean" {
|
||||
t.Errorf("ProviderHint = %q, want %q", p.ProviderHint, "clean")
|
||||
}
|
||||
if p.Files[0].Src != dirtySrc {
|
||||
t.Errorf("files[].src изменён санитайзингом: %q != %q", p.Files[0].Src, dirtySrc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePlan_LogsOnChange(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
p := Plan{Title: "Hаrold"} // 'а' — кириллица, санитайзинг перепишет
|
||||
sanitizePlan(&p, log)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "recognition plan field sanitized") || !strings.Contains(out, "field=title") {
|
||||
t.Errorf("ожидался Debug-лог о санитайзинге title, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePlan_NoLogWhenClean(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
p := Plan{Title: "Harold", OriginalTitle: "The Matrix"}
|
||||
sanitizePlan(&p, log)
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("для чистых полей лога быть не должно, got: %q", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package recognize
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -12,7 +13,11 @@ import (
|
||||
// parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему.
|
||||
// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Структурные
|
||||
// предупреждения (см. decide) ошибкой не считаются — они уводят в review.
|
||||
func parsePlan(raw string, in Input) (Plan, error) {
|
||||
//
|
||||
// Человекочитаемые поля плана санитизируются как недоверенный вывод LLM (см.
|
||||
// sanitizePlan) ДО структурной валидации: так, например, title из одних
|
||||
// zero-width символов схлопывается в пустой и корректно уводит в ретрай.
|
||||
func parsePlan(raw string, in Input, log *slog.Logger) (Plan, error) {
|
||||
jsonStr, err := llm.ExtractJSONObject(raw)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("no JSON object in response")
|
||||
@@ -29,6 +34,8 @@ func parsePlan(raw string, in Input) (Plan, error) {
|
||||
}
|
||||
}
|
||||
|
||||
sanitizePlan(&p, log)
|
||||
|
||||
if err := validateSchema(&p, in); err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestParsePlan_FencedJSON(t *testing.T) {
|
||||
in := inputWith("film.mkv")
|
||||
raw := "Вот результат:\n```json\n{\"type\":\"movie\",\"title\":\"Film\"," +
|
||||
"\"files\":[{\"src\":\"film.mkv\",\"role\":\"main\"}]}\n```"
|
||||
p, err := parsePlan(raw, in)
|
||||
p, err := parsePlan(raw, in, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("parsePlan: %v", err)
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestParsePlan_UnknownFieldTolerated(t *testing.T) {
|
||||
in := inputWith("film.mkv")
|
||||
raw := `{"type":"movie","title":"Film","extra_field":123,
|
||||
"files":[{"src":"film.mkv","role":"main"}]}`
|
||||
if _, err := parsePlan(raw, in); err != nil {
|
||||
if _, err := parsePlan(raw, in, testLogger()); err != nil {
|
||||
t.Fatalf("unknown field should be tolerated: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+113
-3
@@ -183,8 +183,13 @@ type Download struct {
|
||||
// сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в
|
||||
// формате RFC 3339 (UTC, суффикс Z), как created_at.
|
||||
SourceAddedAt sql.NullString `db:"source_added_at"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
// RetriedAt — время последнего ручного retry (RFC 3339 UTC, суффикс Z), NULL
|
||||
// пока задачу не повторяли. Приподнимает базис отсчёта таймаутов, чтобы
|
||||
// возврат в downloading не ронял задачу снова на ближайшем тике (см.
|
||||
// state-reconciliation «Ручной повтор»).
|
||||
RetriedAt sql.NullString `db:"retried_at"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
|
||||
// Infohashes — хеши загрузки (download_infohash); подгружаются вместе с
|
||||
// записью методами чтения store (v1 раньше v2 — порядок стабильный).
|
||||
@@ -236,6 +241,19 @@ func Now() time.Time { return time.Now().UTC() }
|
||||
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
|
||||
func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) }
|
||||
|
||||
// RetriedTime возвращает время последнего ручного retry (UTC) и ok=false, если
|
||||
// задачу ещё не повторяли (retried_at NULL) или метку не разобрать.
|
||||
func (d Download) RetriedTime() (time.Time, bool) {
|
||||
if !d.RetriedAt.Valid {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := ParseTime(d.RetriedAt.String)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// NullString строит sql.NullString: пустая строка → NULL.
|
||||
func NullString(s string) sql.NullString {
|
||||
return sql.NullString{String: s, Valid: s != ""}
|
||||
@@ -272,8 +290,18 @@ func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashe
|
||||
// Дедуп нашёл активного владельца по одному из хешей — остальные хеши
|
||||
// norm принадлежат тому же торренту (гибридный magnet): дописываем
|
||||
// недостающие, иначе второй хеш молча теряется и последующий приём по
|
||||
// нему создал бы вторую активную задачу.
|
||||
// нему создал бы вторую активную задачу. Дозапись — под тем же пер-хеш
|
||||
// гардом владения, что и AddInfohashes: хеш, которым владеет ДРУГАЯ
|
||||
// активная задача (split-identity/крафт-магнет), не дописываем, иначе
|
||||
// две активные владели бы одним инфохэшем (нарушение инварианта).
|
||||
for _, h := range norm {
|
||||
owner, err := findActiveByInfohash(ctx, tx, []string{h}, existing.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create download: %w", err)
|
||||
}
|
||||
if owner != nil {
|
||||
continue // чужой активный владелец — не крадём хеш
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`,
|
||||
existing.ID, h, HashKind(h), now); err != nil {
|
||||
@@ -335,6 +363,57 @@ func (s *Store) GetTorrentData(ctx context.Context, downloadID string) ([]byte,
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// UpgradeCatchedMagnetToTorrent апгрейдит пойманную magnet-задачу до torrent
|
||||
// при дедупе входящих байтов `.torrent`: в одной write-транзакции сохраняет
|
||||
// байты и меняет source_type magnet→torrent, но ТОЛЬКО пока задача в `catched`
|
||||
// (воркер источник ещё не отдал в qBittorrent) и её source_type всё ещё
|
||||
// `magnet`. Это целевое исключение из правила «при дедупе байты не сохраняем»:
|
||||
// magnet на закрытом трекере без DHT метаданные не докачает и застрянет в
|
||||
// metaDL, а поданный пользователем `.torrent` их несёт. Возвращает true, если
|
||||
// апгрейд применён; (false, nil) — если задача не подходит (уже добавлена,
|
||||
// отменена или не magnet) либо байты пусты (защитный no-op). Гард через
|
||||
// RowsAffected UPDATE — та же ре-валидация состояния, что у PromoteCatched.
|
||||
func (s *Store) UpgradeCatchedMagnetToTorrent(ctx context.Context, downloadID string, torrentBlob []byte) (bool, error) {
|
||||
if len(torrentBlob) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("upgrade %s to torrent: begin tx: %w", downloadID, err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
UPDATE download
|
||||
SET source_type = ?, updated_at = ?
|
||||
WHERE id = ? AND source_type = ? AND state = ?`,
|
||||
string(SourceTorrent), FormatTime(Now()), downloadID, string(SourceMagnet), string(StateCatched))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("upgrade %s to torrent: %w", downloadID, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("upgrade %s to torrent: %w", downloadID, err)
|
||||
}
|
||||
if n == 0 {
|
||||
// Не catched-magnet (уже добавлена/отменена/torrent) — апгрейд не нужен.
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, fmt.Errorf("upgrade %s to torrent: commit: %w", downloadID, err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
// У magnet-задачи блоба не было; OR REPLACE — страховка идемпотентности.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT OR REPLACE INTO download_torrent (download_id, data) VALUES (?, ?)`,
|
||||
downloadID, torrentBlob); err != nil {
|
||||
return false, fmt.Errorf("upgrade %s to torrent: store blob: %w", downloadID, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, fmt.Errorf("upgrade %s to torrent: commit: %w", downloadID, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ActivateIfNoOtherActive атомарно возвращает загрузку в активное состояние
|
||||
// (retry/восстановление сверкой/relink): в одной write-транзакции проверяет,
|
||||
// что никакая ДРУГАЯ активная загрузка не владеет любым из хешей этой, и
|
||||
@@ -443,6 +522,22 @@ func (s *Store) SetSourceAddedAt(ctx context.Context, id string, t time.Time) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRetriedAt проставляет время ручного retry задачи (сброс базиса отсчёта
|
||||
// таймаутов, см. state-reconciliation «Ручной повтор»). В отличие от
|
||||
// SetSourceAddedAt перезаписывает значение: retry можно повторять, и базис
|
||||
// должен смещаться на каждый.
|
||||
func (s *Store) SetRetriedAt(ctx context.Context, id string, t time.Time) error {
|
||||
res, err := s.DB.ExecContext(ctx,
|
||||
`UPDATE download SET retried_at = ? WHERE id = ?`, FormatTime(t), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set download %s retried_at: %w", id, err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return fmt.Errorf("set download %s retried_at: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListDownloads возвращает все загрузки, новые сверху (id — ULID, сортировка
|
||||
// по нему хронологична).
|
||||
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
|
||||
@@ -624,6 +719,21 @@ WHERE id = ? AND state = ?`,
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDisplayName обновляет отображаемое имя загрузки постфактум — перелив
|
||||
// канонического имени после распознавания (см. capability ingest). В отличие от
|
||||
// PromoteCatched не двигает FSM и не завязан на состояние (гарда state нет):
|
||||
// обновление имени валидно и в review, и в терминальных done/orphaned. Имя —
|
||||
// косметика (заголовок в UI + ярлык раздачи), пути на диске не затрагивает.
|
||||
func (s *Store) SetDisplayName(ctx context.Context, id, name string) error {
|
||||
if _, err := s.DB.ExecContext(ctx, `
|
||||
UPDATE download
|
||||
SET display_name = ?, updated_at = ?
|
||||
WHERE id = ?`, name, FormatTime(Now()), id); err != nil {
|
||||
return fmt.Errorf("set display name %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда
|
||||
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
|
||||
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу.
|
||||
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -36,6 +37,34 @@ func newCatched() *Download {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDisplayNamePostHoc(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
d := newDownloading()
|
||||
d.DisplayName = "Unknown"
|
||||
if existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(1)}, nil); err != nil || existing != nil {
|
||||
t.Fatalf("create: existing=%v err=%v", existing, err)
|
||||
}
|
||||
// Терминальное состояние — SetDisplayName не завязан на state (в отличие от
|
||||
// PromoteCatched) и должен обновлять имя и на done.
|
||||
forceState(t, st, d.ID, StateDone)
|
||||
|
||||
if err := st.SetDisplayName(ctx, d.ID, "Harold and the Purple Crayon (2024)"); err != nil {
|
||||
t.Fatalf("SetDisplayName: %v", err)
|
||||
}
|
||||
got, err := st.GetDownload(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDownload: %v", err)
|
||||
}
|
||||
if got.DisplayName != "Harold and the Purple Crayon (2024)" {
|
||||
t.Errorf("display_name = %q", got.DisplayName)
|
||||
}
|
||||
if got.State != StateDone {
|
||||
t.Errorf("state = %q, SetDisplayName не должен двигать FSM", got.State)
|
||||
}
|
||||
}
|
||||
|
||||
// catched — нетерминальное активное состояние: его наличие блокирует повторный
|
||||
// приём того же infohash (инвариант «≤1 активная на infohash»).
|
||||
func TestCatchedIsActiveForDedup(t *testing.T) {
|
||||
@@ -167,6 +196,50 @@ func TestFindActiveByInfohash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// F1: дедуп-ветка CreateDownloadIfNoActive доносит недостающие хеши под пер-хеш
|
||||
// гардом владения — хеш, которым владеет ДРУГАЯ активная задача (split-identity
|
||||
// гибрида), красть не должна, иначе две активные владели бы одним инфохэшем.
|
||||
func TestDedupTopUpDoesNotStealForeignHash(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
v1 := hashN(1)
|
||||
v2 := hashN(2)
|
||||
|
||||
// A владеет v1, B владеет v2 — две активные задачи одного гибрида (split).
|
||||
idA := mustCreate(t, st, v1)
|
||||
idB := mustCreate(t, st, v2)
|
||||
|
||||
// Приём гибрида {v1, v2} → дедуп на одну из активных; чужой хеш не крадём.
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if existing == nil {
|
||||
t.Fatal("ожидался дедуп на активную задачу")
|
||||
}
|
||||
|
||||
assertHashes(t, st, idA, v1) // A по-прежнему владеет только v1
|
||||
assertHashes(t, st, idB, v2) // B по-прежнему владеет только v2
|
||||
}
|
||||
|
||||
// assertHashes проверяет точный набор инфохэшей загрузки.
|
||||
func assertHashes(t *testing.T, st *Store, id string, want ...string) {
|
||||
t.Helper()
|
||||
d, err := st.GetDownload(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", id, err)
|
||||
}
|
||||
got := d.HashList()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s hashes = %v, want %v", id, got, want)
|
||||
}
|
||||
for _, w := range want {
|
||||
if !slices.Contains(got, w) {
|
||||
t.Fatalf("%s hashes = %v, want содержит %s", id, got, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Состояния рассинхрона (target_missing/orphaned/deleted) терминальны: задача
|
||||
// в них не должна считаться «активной» (иначе relink/ingest-дедуп решат, что
|
||||
// для infohash уже есть активная задача). Регрессия: FindActiveByInfohash и
|
||||
|
||||
@@ -225,6 +225,34 @@ func TestSetSourceAddedAtOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRetriedAtOverwrites(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id := mkDownload(t, st, 1, StateStuck, "x")
|
||||
first := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
||||
second := time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := st.SetRetriedAt(ctx, id, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// В отличие от source_added_at — повторный retry перезаписывает базис.
|
||||
if err := st.SetRetriedAt(ctx, id, second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := st.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !d.RetriedAt.Valid || d.RetriedAt.String != FormatTime(second) {
|
||||
t.Fatalf("retried_at = %q, want %q (последний retry)", d.RetriedAt.String, FormatTime(second))
|
||||
}
|
||||
got, ok := d.RetriedTime()
|
||||
if !ok || !got.Equal(second) {
|
||||
t.Fatalf("RetriedTime() = %v, %v; want %v, true", got, ok, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDownloadsPageRecTitleFallback(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- Время последнего ручного retry задачи (RFC 3339 UTC, суффикс Z). Приподнимает
|
||||
-- базис отсчёта таймаутов (magnet_timeout/stuck_after): после retry отсчёт идёт
|
||||
-- от max(базис_добавления или last_activity, retried_at), чтобы возврат в
|
||||
-- downloading не ронял задачу снова на ближайшем тике (см. state-reconciliation
|
||||
-- «Ручной повтор зависшей/упавшей загрузки»). Хранится в БД, а не в памяти,
|
||||
-- чтобы сброс базиса пережил интервал поллинга и рестарт процесса.
|
||||
ALTER TABLE download ADD COLUMN retried_at TEXT;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE download DROP COLUMN retried_at;
|
||||
@@ -293,6 +293,32 @@ func (s *Store) LayoutSizeByDownload(ctx context.Context, ids []string) (map[str
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LiveTitleFolders возвращает целевые пути (dst_path) живых ссылок
|
||||
// (`status IN linked/copied/exists`) загрузок, чьё current recognition имеет тот
|
||||
// же (provider, provider_id), КРОМЕ excludeDownloadID. Питает правило сходимости
|
||||
// папки (см. file-layout): вызывающий сводит пути к папкам тайтла, проверяет их
|
||||
// существование на диске и наследует базу имени. Пустой provider/id → пусто (без
|
||||
// подтверждённого матча якоря нет). Проверка живости папки на диске —
|
||||
// ответственность вызывающего (статус ссылки не гарантирует наличие папки).
|
||||
func (s *Store) LiveTitleFolders(ctx context.Context, provider, providerID, excludeDownloadID string) ([]string, error) {
|
||||
if provider == "" || providerID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
const q = `
|
||||
SELECT fl.dst_path
|
||||
FROM file_link fl
|
||||
JOIN recognition r ON r.download_id = fl.download_id AND r.is_current = 1
|
||||
WHERE fl.status IN ('linked', 'copied', 'exists')
|
||||
AND fl.download_id != ?
|
||||
AND r.provider = ?
|
||||
AND r.provider_id = ?`
|
||||
var out []string
|
||||
if err := s.DB.SelectContext(ctx, &out, q, excludeDownloadID, provider, providerID); err != nil {
|
||||
return nil, fmt.Errorf("live title folders: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteFileLinksByBatch удаляет записи ссылок батча (после undo на ФС).
|
||||
func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) error {
|
||||
if _, err := s.DB.ExecContext(ctx,
|
||||
|
||||
@@ -289,6 +289,66 @@ func TestCandidates_Lifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveTitleFolders(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// anchor и other сматчены на один (tvdb, 269613); alien — на другой матч.
|
||||
anchor := mustCreate(t, st, "1100ccddeeff00112233445566778899aabbccdd")
|
||||
other := mustCreate(t, st, "2200ccddeeff00112233445566778899aabbccdd")
|
||||
alien := mustCreate(t, st, "3300ccddeeff00112233445566778899aabbccdd")
|
||||
|
||||
seedMatch := func(dl, provider, id string) {
|
||||
if _, err := st.CreateRecognition(ctx, &Recognition{
|
||||
DownloadID: dl, Provider: NullString(provider), ProviderID: NullString(id),
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("seed recognition: %v", err)
|
||||
}
|
||||
}
|
||||
seedMatch(anchor, "tvdb", "269613")
|
||||
seedMatch(other, "tvdb", "269613")
|
||||
seedMatch(alien, "tvdb", "999")
|
||||
|
||||
if err := st.CreateFileLinks(ctx, []FileLink{
|
||||
{DownloadID: anchor, ApplyBatchID: "a", SrcPath: "/d/a.mkv", DstPath: "/s/Фарго (2014) [tvdbid-269613]/Season 01/e.mkv", Kind: "video", Status: "linked"},
|
||||
{DownloadID: other, ApplyBatchID: "o", SrcPath: "/d/o.mkv", DstPath: "/s/Фарго (2014) [tvdbid-269613]/Season 02/e.mkv", Kind: "video", Status: "exists"},
|
||||
// superseded/collision у other — не живые, не должны попасть.
|
||||
{DownloadID: other, ApplyBatchID: "o", SrcPath: "/d/x.mkv", DstPath: "/s/Old/e.mkv", Kind: "video", Status: "superseded"},
|
||||
{DownloadID: other, ApplyBatchID: "o", SrcPath: "/d/y.mkv", DstPath: "/s/Coll/e.mkv", Kind: "video", Status: "collision"},
|
||||
// alien — другой матч, мимо.
|
||||
{DownloadID: alien, ApplyBatchID: "z", SrcPath: "/d/z.mkv", DstPath: "/s/Other/e.mkv", Kind: "video", Status: "linked"},
|
||||
}); err != nil {
|
||||
t.Fatalf("create links: %v", err)
|
||||
}
|
||||
|
||||
// Из позиции anchor: видим только живую ссылку other (себя исключаем).
|
||||
got, err := st.LiveTitleFolders(ctx, "tvdb", "269613", anchor)
|
||||
if err != nil {
|
||||
t.Fatalf("live title folders: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "/s/Фарго (2014) [tvdbid-269613]/Season 02/e.mkv" {
|
||||
t.Errorf("got %v, want только живую ссылку other", got)
|
||||
}
|
||||
|
||||
// Без исключения — обе живые ссылки матча (anchor + other), но не superseded/
|
||||
// collision/alien.
|
||||
got, err = st.LiveTitleFolders(ctx, "tvdb", "269613", "")
|
||||
if err != nil {
|
||||
t.Fatalf("live title folders: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("got %v, want 2 живые ссылки", got)
|
||||
}
|
||||
|
||||
// Пустой provider/id → пусто (нет подтверждённого матча).
|
||||
if got, _ := st.LiveTitleFolders(ctx, "", "269613", ""); got != nil {
|
||||
t.Errorf("пустой provider → пусто, got %v", got)
|
||||
}
|
||||
if got, _ := st.LiveTitleFolders(ctx, "tvdb", "", ""); got != nil {
|
||||
t.Errorf("пустой id → пусто, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCandidate_None(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
c, err := st.GetCandidate(context.Background(), "01hzzzzzzzzzzzzzzzzzzzzzzz")
|
||||
|
||||
@@ -65,3 +65,110 @@ func TestTorrentBlobMissing(t *testing.T) {
|
||||
t.Errorf("ожидался ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// F6: апгрейд пойманного magnet до torrent сохраняет байты и меняет source_type.
|
||||
func TestUpgradeCatchedMagnetToTorrent(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
d := &Download{SourceType: SourceMagnet, SourceRef: "magnet:?xt=urn:btih:" + hashN(1), State: StateCatched}
|
||||
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(1)}, nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
blob := []byte("d8:announce…real torrent")
|
||||
upgraded, err := st.UpgradeCatchedMagnetToTorrent(ctx, d.ID, blob)
|
||||
if err != nil || !upgraded {
|
||||
t.Fatalf("upgrade: upgraded=%v err=%v", upgraded, err)
|
||||
}
|
||||
|
||||
got, err := st.GetDownload(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.SourceType != SourceTorrent {
|
||||
t.Errorf("source_type = %q, want torrent", got.SourceType)
|
||||
}
|
||||
data, err := st.GetTorrentData(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get torrent data: %v", err)
|
||||
}
|
||||
if string(data) != string(blob) {
|
||||
t.Errorf("blob = %q, want %q", data, blob)
|
||||
}
|
||||
}
|
||||
|
||||
// Апгрейд применим только в catched: уже добавленный (downloading) magnet не
|
||||
// трогаем — его судьба решается retry/сверкой, а не приёмом.
|
||||
func TestUpgradeSkippedWhenNotCatched(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
d := &Download{SourceType: SourceMagnet, SourceRef: "m", State: StateDownloading}
|
||||
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(2)}, nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
upgraded, err := st.UpgradeCatchedMagnetToTorrent(ctx, d.ID, []byte("bytes"))
|
||||
if err != nil {
|
||||
t.Fatalf("upgrade: %v", err)
|
||||
}
|
||||
if upgraded {
|
||||
t.Error("downloading-magnet апгрейдить не должны")
|
||||
}
|
||||
got, err := st.GetDownload(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.SourceType != SourceMagnet {
|
||||
t.Errorf("source_type сменился на %q, а не должен", got.SourceType)
|
||||
}
|
||||
if _, err := st.GetTorrentData(ctx, d.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("байты не должны сохраняться, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Не-magnet источник апгрейд не трогает (гард source_type=magnet), байты не
|
||||
// перезаписывает.
|
||||
func TestUpgradeSkippedForTorrentSource(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
d := &Download{SourceType: SourceTorrent, SourceRef: "Rel", State: StateCatched}
|
||||
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(3)}, []byte("orig")); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
upgraded, err := st.UpgradeCatchedMagnetToTorrent(ctx, d.ID, []byte("new"))
|
||||
if err != nil {
|
||||
t.Fatalf("upgrade: %v", err)
|
||||
}
|
||||
if upgraded {
|
||||
t.Error("torrent-источник апгрейдить не нужно")
|
||||
}
|
||||
data, err := st.GetTorrentData(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get torrent data: %v", err)
|
||||
}
|
||||
if string(data) != "orig" {
|
||||
t.Errorf("байты перезаписаны: %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
// Пустые байты — защитный no-op.
|
||||
func TestUpgradeEmptyBlobNoOp(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
d := &Download{SourceType: SourceMagnet, SourceRef: "m", State: StateCatched}
|
||||
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(4)}, nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
upgraded, err := st.UpgradeCatchedMagnetToTorrent(ctx, d.ID, nil)
|
||||
if err != nil || upgraded {
|
||||
t.Fatalf("пустой блоб: upgraded=%v err=%v", upgraded, err)
|
||||
}
|
||||
got, err := st.GetDownload(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.SourceType != SourceMagnet {
|
||||
t.Errorf("source_type сменился на %q при пустом блобе", got.SourceType)
|
||||
}
|
||||
}
|
||||
|
||||
+61
-19
@@ -7,7 +7,6 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -16,6 +15,8 @@ import (
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
|
||||
@@ -45,6 +46,7 @@ type Reviewer interface {
|
||||
Defer(ctx context.Context, id string) error
|
||||
Cancel(ctx context.Context, id string) error
|
||||
Retry(ctx context.Context, id string) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// Config — параметры бота.
|
||||
@@ -105,6 +107,12 @@ func (b *Bot) Run(ctx context.Context) {
|
||||
return
|
||||
case u, ok := <-updates:
|
||||
if !ok {
|
||||
// Канал закрыт не по нашей отмене (ctx ещё жив) — библиотека
|
||||
// прекратила приём обновлений: иначе бот молча перестал бы
|
||||
// реагировать без следа в логах.
|
||||
if ctx.Err() == nil {
|
||||
b.log.Error("telegram updates channel closed unexpectedly")
|
||||
}
|
||||
return
|
||||
}
|
||||
b.handleUpdate(ctx, u)
|
||||
@@ -146,6 +154,8 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
||||
// Ждём подсказку для перераспознавания?
|
||||
if id, ok := b.takePending(m.Chat.ID); ok && !strings.Contains(text, "magnet:") {
|
||||
if err := b.reviewer.Refine(ctx, id, text); err != nil {
|
||||
// Ошибку логирует доменная граница (worker.Refine); транспорт лишь
|
||||
// показывает пользователю (logging.md: «транспорты не логируют»).
|
||||
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
|
||||
return
|
||||
}
|
||||
@@ -160,6 +170,9 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
||||
|
||||
source, context, ok := ParseMessage(text)
|
||||
if !ok {
|
||||
// Невалидный ввод — норма (пользователь получит отказ): DEBUG, чтобы
|
||||
// при разборе «почему не приняло» отказ был виден в логах.
|
||||
b.log.Debug("telegram source not recognized", "chat_id", m.Chat.ID)
|
||||
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота, пришлите magnet или .torrent-файл.", nil)
|
||||
return
|
||||
}
|
||||
@@ -171,10 +184,12 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
||||
func (b *Bot) handleDocument(ctx context.Context, m *tgbotapi.Message) {
|
||||
doc := m.Document
|
||||
if !isTorrentDoc(doc) {
|
||||
b.log.Debug("telegram document rejected", "chat_id", m.Chat.ID, "reason", "not-torrent", "mime", doc.MimeType)
|
||||
b.send(m.Chat.ID, "Это не .torrent-файл. Пришлите magnet-ссылку или .torrent.", nil)
|
||||
return
|
||||
}
|
||||
if doc.FileSize > 0 && doc.FileSize > ingest.MaxTorrentSize {
|
||||
b.log.Debug("telegram document rejected", "chat_id", m.Chat.ID, "reason", "too-large", "size", doc.FileSize)
|
||||
b.send(m.Chat.ID, "Файл слишком большой для .torrent.", nil)
|
||||
return
|
||||
}
|
||||
@@ -191,16 +206,6 @@ func (b *Bot) handleDocument(ctx context.Context, m *tgbotapi.Message) {
|
||||
})
|
||||
}
|
||||
|
||||
// stripURL убирает URL из ошибки *url.Error (URL файла Telegram содержит токен
|
||||
// бота), оставляя только первопричину — защита от утечки секрета в логи.
|
||||
func stripURL(err error) error {
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return ue.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// isTorrentDoc — документ выглядит как .torrent (по mime или расширению).
|
||||
func isTorrentDoc(doc *tgbotapi.Document) bool {
|
||||
if doc == nil {
|
||||
@@ -217,20 +222,22 @@ func isTorrentDoc(doc *tgbotapi.Document) bool {
|
||||
//
|
||||
// ВАЖНО: прямой URL файла Telegram содержит токен бота
|
||||
// (…/file/bot<TOKEN>/<path>). Ошибки транспорта (*url.Error) встраивают этот
|
||||
// URL в текст — их нельзя возвращать/логировать как есть. stripURL оставляет
|
||||
// только первопричину без URL, чтобы токен не утёк в логи (см. logging.md).
|
||||
// URL в текст — их нельзя возвращать/логировать как есть. logging.SanitizeErr
|
||||
// оставляет только первопричину без URL, чтобы токен не утёк в логи (см.
|
||||
// logging.md). GetFileDirectURL тоже ходит в Bot API (…/bot<TOKEN>/getFile) —
|
||||
// его ошибку санитизируем так же.
|
||||
func (b *Bot) downloadFile(ctx context.Context, fileID string) ([]byte, error) {
|
||||
fileURL, err := b.api.GetFileDirectURL(fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file url: %w", err)
|
||||
return nil, fmt.Errorf("file url: %w", logging.SanitizeErr(err))
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram file request: %w", stripURL(err))
|
||||
return nil, fmt.Errorf("telegram file request: %w", logging.SanitizeErr(err))
|
||||
}
|
||||
resp, err := b.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram file GET: %w", stripURL(err))
|
||||
return nil, fmt.Errorf("telegram file GET: %w", logging.SanitizeErr(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -305,6 +312,20 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
case "retry":
|
||||
err = b.reviewer.Retry(ctx, id)
|
||||
note = "Повторяю…"
|
||||
case "delete":
|
||||
// Первый шаг: показываем подтверждение (необратимое действие — снос
|
||||
// раздачи+файлов и хардлинков). Домен не трогаем, меняем клавиатуру.
|
||||
b.answer(cq.ID, "")
|
||||
b.editMarkup(chatID, msgID, b.deleteConfirmKeyboard(id))
|
||||
return
|
||||
case "delete_cancel":
|
||||
// Отмена подтверждения — возвращаем обычную карточку.
|
||||
b.answer(cq.ID, "Отменено")
|
||||
b.refreshCard(ctx, chatID, msgID, id)
|
||||
return
|
||||
case "delete_confirm":
|
||||
err = b.reviewer.Delete(ctx, id)
|
||||
note = "Удаляю…"
|
||||
case "type":
|
||||
err = b.reviewer.SetType(ctx, id, val)
|
||||
note = "Меняю тип…"
|
||||
@@ -325,6 +346,16 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, layout.ErrCollision) {
|
||||
// Коллизия цели: задача штатно ушла в review с причиной — показываем
|
||||
// конкретно и обновляем карточку (в ней теперь причина коллизии).
|
||||
b.answer(cq.ID, "Коллизия цели")
|
||||
b.send(chatID, opErr("Целевой файл уже существует — задача отправлена в ревью", id), nil)
|
||||
b.refreshCard(ctx, chatID, msgID, id)
|
||||
return
|
||||
}
|
||||
// Ошибку логирует доменная граница (соответствующая команда worker);
|
||||
// транспорт лишь переводит её в ответ пользователю (logging.md).
|
||||
b.answer(cq.ID, "Ошибка")
|
||||
b.send(chatID, opErr("Не удалось выполнить действие", id), nil)
|
||||
return
|
||||
@@ -348,7 +379,7 @@ func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id strin
|
||||
edit = tgbotapi.NewEditMessageText(chatID, msgID, text)
|
||||
}
|
||||
if _, err := b.api.Send(edit); err != nil {
|
||||
b.log.Warn("telegram edit card failed", "download_id", id, "error", err)
|
||||
b.log.Warn("telegram edit card failed", "download_id", id, "error", logging.SanitizeErr(err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,13 +418,24 @@ func (b *Bot) send(chatID int64, text string, kb *tgbotapi.InlineKeyboardMarkup)
|
||||
msg.ReplyMarkup = *kb
|
||||
}
|
||||
if _, err := b.api.Send(msg); err != nil {
|
||||
b.log.Warn("telegram send failed", "chat_id", chatID, "error", err)
|
||||
b.log.Warn("telegram send failed", "chat_id", chatID, "error", logging.SanitizeErr(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) answer(callbackID, text string) {
|
||||
if _, err := b.api.Request(tgbotapi.NewCallback(callbackID, text)); err != nil {
|
||||
b.log.Warn("telegram answer callback failed", "error", err)
|
||||
b.log.Warn("telegram answer callback failed", "error", logging.SanitizeErr(err))
|
||||
}
|
||||
}
|
||||
|
||||
// editMarkup подменяет только клавиатуру сообщения на месте (текст не трогаем).
|
||||
// Используется для шага подтверждения удаления.
|
||||
func (b *Bot) editMarkup(chatID int64, msgID int, kb *tgbotapi.InlineKeyboardMarkup) {
|
||||
if kb == nil {
|
||||
return
|
||||
}
|
||||
if _, err := b.api.Send(tgbotapi.NewEditMessageReplyMarkup(chatID, msgID, *kb)); err != nil {
|
||||
b.log.Warn("telegram edit markup failed", "chat_id", chatID, "error", logging.SanitizeErr(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ type fakeReviewer struct {
|
||||
deferred []string
|
||||
canceled []string
|
||||
retried []string
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
|
||||
@@ -104,6 +105,10 @@ func (f *fakeReviewer) Retry(_ context.Context, id string) error {
|
||||
f.retried = append(f.retried, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Delete(_ context.Context, id string) error {
|
||||
f.deleted = append(f.deleted, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
|
||||
const tid = "01arz3ndektsv4rrffq69g5fav"
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestBot_DocumentByExtension(t *testing.T) {
|
||||
|
||||
// Ошибка скачивания не должна утекать токен бота (URL файла Telegram содержит
|
||||
// …/bot<TOKEN>/…). Транспортная ошибка *url.Error встраивает URL — проверяем,
|
||||
// что stripURL его убрал.
|
||||
// что logging.SanitizeErr его убрал.
|
||||
func TestBot_DownloadErrorNoTokenLeak(t *testing.T) {
|
||||
b, api, _, _ := newTestBot(t, []int64{7})
|
||||
// «Токен» в URL, указывающем на закрытый порт → ошибка транспорта.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
// SetLibraryLogger направляет внутренние логи клиента tgbotapi в наш slog.
|
||||
//
|
||||
// Зачем: библиотека логирует сбои long-poll `getUpdates` через собственный
|
||||
// (stdlib `log`) логгер — МИМО slog. Из-за этого сетевые/API-ошибки поллинга не
|
||||
// попадали в структурированные JSON-логи: входящее сообщение молча не
|
||||
// подхватывалось, а в логах — пусто (см. logging.md). После вызова такие сбои
|
||||
// видны как `telegram library` (WARN).
|
||||
//
|
||||
// Токен вырезается из текста: строка ошибки транспорта — `*url.Error` с URL вида
|
||||
// `…/bot<TOKEN>/getUpdates`, писать её как есть нельзя (утечка секрета в логи).
|
||||
// Замена по подстроке страхует и от прочих мест, где токен мог бы просочиться.
|
||||
//
|
||||
// Логгер в tgbotapi — глобальный на пакет; вызывать один раз при старте.
|
||||
func SetLibraryLogger(log *slog.Logger, token string) error {
|
||||
return tgbotapi.SetLogger(libLogger{log: log, token: token})
|
||||
}
|
||||
|
||||
type libLogger struct {
|
||||
log *slog.Logger
|
||||
token string
|
||||
}
|
||||
|
||||
func (l libLogger) Println(v ...any) { l.emit(fmt.Sprintln(v...)) }
|
||||
|
||||
func (l libLogger) Printf(format string, v ...any) { l.emit(fmt.Sprintf(format, v...)) }
|
||||
|
||||
func (l libLogger) emit(msg string) {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if l.token != "" {
|
||||
msg = strings.ReplaceAll(msg, l.token, "***")
|
||||
}
|
||||
// Сбои поллинга транзиентны (библиотека повторяет через 3 с) — WARN
|
||||
// («retry внешнего вызова»); устойчивый сбой станет потоком WARN — сигнал
|
||||
// разбираться, но не ERROR на каждый повтор.
|
||||
l.log.Warn("telegram library", "detail", msg)
|
||||
}
|
||||
@@ -24,15 +24,19 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
||||
case store.StateLinking:
|
||||
return "⏳ Раскладываю #" + id + "…", nil
|
||||
case store.StateDone:
|
||||
return b.renderDone(rd), b.webOnly(id)
|
||||
return b.renderDone(rd), b.deletableKeyboard(id)
|
||||
default:
|
||||
text := fmt.Sprintf("Задача #%s — %s.", id, state)
|
||||
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
||||
text += "\n" + msg
|
||||
}
|
||||
// failed/stuck — даём кнопку повтора; остальное только «в вебе».
|
||||
if state == store.StateFailed || state == store.StateStuck {
|
||||
switch state {
|
||||
case store.StateFailed, store.StateStuck:
|
||||
// failed/stuck — даём кнопку повтора.
|
||||
return text, b.retryKeyboard(id)
|
||||
case store.StateOrphaned, store.StateTargetMissing:
|
||||
// Рассинхрон — можно подчистить остатки полным удалением.
|
||||
return text, b.deletableKeyboard(id)
|
||||
}
|
||||
return text, b.webOnly(id)
|
||||
}
|
||||
@@ -164,6 +168,29 @@ func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
return &kb
|
||||
}
|
||||
|
||||
// deletableKeyboard — клавиатура состояний, откуда доступно полное удаление
|
||||
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Удалить». Само удаление
|
||||
// двухшаговое — кнопка ведёт на подтверждение (deleteConfirmKeyboard).
|
||||
func (b *Bot) deletableKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
var row []tgbotapi.InlineKeyboardButton
|
||||
if url := b.reviewURL(id); url != "" {
|
||||
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
|
||||
}
|
||||
row = append(row, tgbotapi.NewInlineKeyboardButtonData("🗑 Удалить", "delete:"+id))
|
||||
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...))
|
||||
return &kb
|
||||
}
|
||||
|
||||
// deleteConfirmKeyboard — шаг подтверждения удаления: необратимое снятие
|
||||
// раздачи+файлов и хардлинков. Явное «Да» отделено от отмены.
|
||||
func (b *Bot) deleteConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("❗ Да, удалить", "delete_confirm:"+id),
|
||||
tgbotapi.NewInlineKeyboardButtonData("Отмена", "delete_cancel:"+id),
|
||||
))
|
||||
return &kb
|
||||
}
|
||||
|
||||
func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
url := b.reviewURL(id)
|
||||
if url == "" {
|
||||
|
||||
@@ -13,3 +13,10 @@ var ErrConflict = errors.New("conflict")
|
||||
// от ErrConflict (тоже 409), потому что причина actionable — «дождись докачки»
|
||||
// — и транспорт показывает её конкретным текстом, а не генериком конфликта.
|
||||
var ErrNotReady = errors.New("source not ready")
|
||||
|
||||
// ErrInvalidInput — команда отклонена из-за некорректного пользовательского
|
||||
// ввода (пустая подсказка, неизвестный тип/провайдер, пустой id, кандидат не из
|
||||
// текущей рекогниции). Это промах пользователя, а не сбой сервера: транспорт
|
||||
// матчит его через errors.Is и отвечает 400, а логирующая граница домена пишет
|
||||
// DEBUG (адресат — пользователь, он уже получил ответ на поверхности).
|
||||
var ErrInvalidInput = errors.New("invalid input")
|
||||
|
||||
@@ -208,7 +208,9 @@ func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byH
|
||||
logctx.From(ctx).Warn("recovery activate failed", "error", err)
|
||||
return
|
||||
}
|
||||
logctx.From(ctx).Info("recovery from failure", "from", d.State, "to", want, "qbit_state", t.State)
|
||||
// Восстановление из failed/stuck — тоже переход состояния: единый msg
|
||||
// `state transition` (from/to), qbit_state — отличительная деталь авто-воскрешения.
|
||||
logctx.From(ctx).Info("state transition", "from", d.State, "to", want, "qbit_state", t.State)
|
||||
}
|
||||
|
||||
// torrentProgressed сообщает, продвинулся ли торрент за условие, по которому
|
||||
|
||||
@@ -151,13 +151,17 @@ func TestReconcileDebounce(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReconcileSkipsActiveStates(t *testing.T) {
|
||||
// downloading сверкой не трогаем, даже если раздачи нет в qBittorrent.
|
||||
f := newReconcileFixture(t, store.StateDownloading, false, true)
|
||||
// Матрица сверки (reconcileDesync) не трогает активные состояния, даже если
|
||||
// раздачи нет в qBittorrent. Берём review (не downloading): активный цикл
|
||||
// поллинга листает только downloading, поэтому review остаётся нетронутым
|
||||
// — пропажу источника у downloading отдельно ведёт правило source_gone
|
||||
// (см. recovery_test: TestSourceGone*).
|
||||
f := newReconcileFixture(t, store.StateReview, 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)
|
||||
if got := f.st.downloads["1"].State; got != store.StateReview {
|
||||
t.Errorf("state = %q, want review (сверка не трогает активные)", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,3 +168,229 @@ func TestRetryReattachesNoReadd(t *testing.T) {
|
||||
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
// addedLongAgo — added_on «5 часов назад» относительно now теста (10:00:00 UTC):
|
||||
// торрент давно в qBittorrent (возраст сам по себе большой).
|
||||
var addedLongAgo = time.Date(2026, 6, 14, 5, 0, 0, 0, time.UTC).Unix()
|
||||
|
||||
// lastActivityLongAgo — last_activity «2 часа назад»: данные давно не двигались.
|
||||
var lastActivityLongAgo = time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC).Unix()
|
||||
|
||||
// MAJOR-1: retry живого, но давно добавленного и простаивающего stalledDL-торрента
|
||||
// сбрасывает базис таймаута (retried_at), поэтому СЛЕДУЮЩИЙ тик не роняет задачу
|
||||
// снова в stuck. Без сброса базиса stallDuration=now−last_activity (2ч) > StuckAfter
|
||||
// (1ч) → задача мгновенно вернулась бы в stuck (регрессия, которую прячет
|
||||
// TestRetryReattachesNoReadd, ставящий added_on/last_activity «минуту назад»).
|
||||
func TestRetryResetsTimeoutBasis(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneFailed(store.StateStuck, errCodeStalled, ih, timeOld)
|
||||
// Торрент жив, но давно добавлен (added_on 5ч) и давно простаивает
|
||||
// (last_activity 2ч) — по старой мере «возраст» он мгновенно снова stuck.
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{
|
||||
Hash: ih, State: "stalledDL",
|
||||
AddedOn: addedLongAgo, LastActivity: lastActivityLongAgo,
|
||||
}}}
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
if err := w.Retry(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Retry: %v", err)
|
||||
}
|
||||
if len(qb.added) != 0 {
|
||||
t.Errorf("живой здоровый торрент не должен добавляться повторно, got %d Add", len(qb.added))
|
||||
}
|
||||
if !st.downloads["1"].RetriedAt.Valid {
|
||||
t.Error("retry должен проставить retried_at (сброс базиса)")
|
||||
}
|
||||
// Ключевая проверка MAJOR-1: следующий тик поллинга.
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := st.downloads["1"].State; got != store.StateDownloading {
|
||||
t.Errorf("после retry задача не должна снова падать в stuck на ближайшем тике, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// MAJOR-2: stuck_after мерит ДЛИТЕЛЬНОСТЬ ПРОСТОЯ (now−last_activity), а не возраст
|
||||
// торрента. Долго качавшийся торрент (added_on 5ч назад) с недавним движением
|
||||
// данных (last_activity 30с назад), на миг зашедший в stalledDL, НЕ уходит в stuck.
|
||||
func TestStallMeasuredFromLastActivity(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
lastActivityRecent := time.Date(2026, 6, 14, 9, 59, 30, 0, time.UTC).Unix() // 30с назад
|
||||
st := oneDownloading(ih, timeOld)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{
|
||||
Hash: ih, State: "stalledDL",
|
||||
AddedOn: addedLongAgo, LastActivity: lastActivityRecent,
|
||||
}}}
|
||||
w := newTestWorker(st, qb)
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := st.downloads["1"].State; got != store.StateDownloading {
|
||||
t.Errorf("свежая активность (30с) — не stuck несмотря на возраст 5ч, got %q", got)
|
||||
}
|
||||
|
||||
// Контроль: тот же торрент, но данные давно не двигались (last_activity 2ч) —
|
||||
// простой превысил StuckAfter (1ч) → stuck.
|
||||
qb.torrents[0].LastActivity = lastActivityLongAgo
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := st.downloads["1"].State; got != store.StateStuck {
|
||||
t.Errorf("простой 2ч > stuck_after 1ч должен дать stuck, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Оборонительный клэмп: last_activity из будущего (перекос часов или sentinel
|
||||
// «никогда не был активен») трактуется как непригодное значение. Иначе простой
|
||||
// вышел бы отрицательным и реально застрявший торрент никогда не пометился бы
|
||||
// stuck. Значение игнорируется, простой считается от базиса добавления (5ч).
|
||||
func TestStallIgnoresFutureLastActivity(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
lastActivityFuture := time.Date(2026, 6, 14, 11, 0, 0, 0, time.UTC).Unix() // 1ч в будущем
|
||||
st := oneDownloading(ih, timeOld)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{
|
||||
Hash: ih, State: "stalledDL",
|
||||
AddedOn: addedLongAgo, LastActivity: lastActivityFuture,
|
||||
}}}
|
||||
w := newTestWorker(st, qb)
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := st.downloads["1"].State; got != store.StateStuck {
|
||||
t.Errorf("last_activity из будущего игнорируется (фолбэк на возраст 5ч > stuck_after 1ч → stuck), got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- MAJOR-3: пропажа источника у активной (downloading) загрузки ---
|
||||
|
||||
// Источник активной загрузки устойчиво пропал из qBittorrent → задача уходит в
|
||||
// failed(source_gone) с уведомлением, а не остаётся вечным зомби.
|
||||
func TestSourceGoneMarksFailed(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneDownloading(ih, timeRecent)
|
||||
w := newTestWorker(st, &fakeQbt{torrents: nil}) // раздачи нет
|
||||
w.cfg.SourceMissingThreshold = 1 // помечаем при первой же пропаже
|
||||
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
|
||||
w.SetNotifier(n)
|
||||
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
d := st.downloads["1"]
|
||||
if d.State != store.StateFailed {
|
||||
t.Fatalf("state = %q, want failed", d.State)
|
||||
}
|
||||
if got := d.ErrorCode.String; got != errCodeSourceGone {
|
||||
t.Errorf("error_code = %q, want %q", got, errCodeSourceGone)
|
||||
}
|
||||
if e := waitNotify(t, n); e.ev != EventFailed {
|
||||
t.Errorf("пинг: ev=%v, want failed", e.ev)
|
||||
}
|
||||
}
|
||||
|
||||
// Кратковременная пропажа (меньше порога) не роняет задачу — дебаунс терпит
|
||||
// транзиентную недоступность qBittorrent (рестарт демона).
|
||||
func TestSourceGoneDebounced(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneDownloading(ih, timeRecent)
|
||||
w := newTestWorker(st, &fakeQbt{torrents: nil})
|
||||
w.cfg.SourceMissingThreshold = 3
|
||||
|
||||
for i := 1; i <= 2; i++ {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll %d: %v", i, err)
|
||||
}
|
||||
if got := st.downloads["1"].State; got != store.StateDownloading {
|
||||
t.Fatalf("tick %d: state = %q, want downloading (до порога)", i, got)
|
||||
}
|
||||
if got := st.downloads["1"].SourceMissCount; got != i {
|
||||
t.Errorf("tick %d: miss = %d, want %d", i, got, i)
|
||||
}
|
||||
}
|
||||
if err := w.Poll(context.Background()); err != nil { // третий промах
|
||||
t.Fatalf("Poll 3: %v", err)
|
||||
}
|
||||
if got := st.downloads["1"].State; got != store.StateFailed {
|
||||
t.Fatalf("tick 3: state = %q, want failed (порог достигнут)", got)
|
||||
}
|
||||
if got := st.downloads["1"].ErrorCode.String; got != errCodeSourceGone {
|
||||
t.Errorf("error_code = %q, want %q", got, errCodeSourceGone)
|
||||
}
|
||||
}
|
||||
|
||||
// Возврат раздачи до порога сбрасывает счётчик промахов и задача ведётся
|
||||
// обычной сверкой (не падает).
|
||||
func TestSourceGoneResetOnReturn(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneDownloading(ih, timeRecent)
|
||||
qb := &fakeQbt{torrents: nil}
|
||||
w := newTestWorker(st, qb)
|
||||
w.cfg.SourceMissingThreshold = 3
|
||||
|
||||
if err := w.Poll(context.Background()); err != nil { // промах 1
|
||||
t.Fatalf("Poll miss: %v", err)
|
||||
}
|
||||
if got := st.downloads["1"].SourceMissCount; got != 1 {
|
||||
t.Fatalf("miss = %d, want 1", got)
|
||||
}
|
||||
// Раздача вернулась (свежий metaDL — не падает по таймауту).
|
||||
qb.torrents = []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll return: %v", err)
|
||||
}
|
||||
if got := st.downloads["1"].State; got != store.StateDownloading {
|
||||
t.Errorf("state = %q, want downloading (источник вернулся)", got)
|
||||
}
|
||||
if got := st.downloads["1"].SourceMissCount; got != 0 {
|
||||
t.Errorf("miss = %d, want 0 (сброс)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// source_gone восстановлению сверкой не подлежит: даже если раздача снова
|
||||
// появилась и продвинулась, reconcileRecovery её не воскрешает — нужен ручной
|
||||
// Retry.
|
||||
func TestSourceGoneNotAutoRecovered(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneFailed(store.StateFailed, errCodeSourceGone, ih, timeOld)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}}
|
||||
w := newTestWorker(st, qb)
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := st.downloads["1"].State; got != store.StateFailed {
|
||||
t.Errorf("state = %q, want failed (source_gone сверкой не воскрешается)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Retry задачи source_gone сбрасывает source_miss_count, чтобы вернувшаяся в
|
||||
// downloading задача получила полное грейс-окно, а не упала снова на ближайшем
|
||||
// тике, если переотданная раздача ещё не видна в qBittorrent.
|
||||
func TestRetryResetsSourceMissCount(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneFailed(store.StateFailed, errCodeSourceGone, ih, timeRecent)
|
||||
st.downloads["1"].SourceMissCount = 3 // задача упала по порогу
|
||||
qb := &fakeQbt{torrents: nil} // раздачи всё ещё нет (Add вслепую)
|
||||
w := newTestWorker(st, qb)
|
||||
w.cfg.SourceMissingThreshold = 3
|
||||
|
||||
if err := w.Retry(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Retry: %v", err)
|
||||
}
|
||||
if got := st.downloads["1"].SourceMissCount; got != 0 {
|
||||
t.Fatalf("после retry miss = %d, want 0 (сброс)", got)
|
||||
}
|
||||
if len(qb.added) != 1 {
|
||||
t.Errorf("retry без живой раздачи должен переотдать источник, got %d Add", len(qb.added))
|
||||
}
|
||||
// Грейс-окно: раздача ещё не видна — задача НЕ падает на ближайшем тике
|
||||
// (miss стал бы 1 < 3, а не 4 при несброшенном счётчике).
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll after retry: %v", err)
|
||||
}
|
||||
if got := st.downloads["1"].State; got != store.StateDownloading {
|
||||
t.Errorf("state = %q, want downloading (грейс-окно не съедено)", got)
|
||||
}
|
||||
if got := st.downloads["1"].SourceMissCount; got != 1 {
|
||||
t.Errorf("miss = %d, want 1 (один промах после сброса)", got)
|
||||
}
|
||||
}
|
||||
|
||||
+326
-67
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||
"git.vakhrushev.me/av/jellybit/internal/naming"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
@@ -132,10 +134,9 @@ func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize
|
||||
planJSON = []byte("{}")
|
||||
}
|
||||
|
||||
provider, providerID, tag := "none", "", ""
|
||||
provider, providerID := "none", ""
|
||||
if res.Match != nil {
|
||||
provider, providerID = res.Match.Provider, res.Match.ProviderID
|
||||
tag = providerTag(res.Match.Provider, res.Match.ProviderID)
|
||||
}
|
||||
|
||||
rec := &store.Recognition{
|
||||
@@ -194,8 +195,16 @@ func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize
|
||||
if res.Decision.Auto && !forceReview && w.layouter != nil {
|
||||
plan := applyOverrides(res.Plan, overrides)
|
||||
lctx := w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
||||
w.transition(lctx, *d, store.StateLinking, "", "")
|
||||
if err := w.linkPlan(lctx, d, plan, tag, savePath); err != nil {
|
||||
// Claim перехода в linking должен закоммититься до хардлинков (MINOR-7):
|
||||
// при провале записи не линкуем — задача остаётся в recognizing, и
|
||||
// поллинг-цикл (recognizePending) повторит распознавание/авто-раскладку.
|
||||
// Правило сходимости папки (и уход в review при рассинхроне) — внутри
|
||||
// linkPlan, из состояния linking.
|
||||
if err := w.transitionErr(lctx, *d, store.StateLinking, "", ""); err != nil {
|
||||
logctx.From(lctx).Warn("auto-apply claim failed, left for recognizing", "error", err)
|
||||
return
|
||||
}
|
||||
if err := w.linkPlan(lctx, d, plan, provider, providerID, savePath); err != nil {
|
||||
logctx.From(lctx).Warn("auto-apply failed, left for review", "error", err)
|
||||
}
|
||||
return
|
||||
@@ -217,7 +226,8 @@ func (w *Worker) overridesOrNil(ctx context.Context, id string) map[string]strin
|
||||
|
||||
// Apply создаёт хардлинки по текущему плану (с применёнными правками) и
|
||||
// переводит задачу в done. Коллизия цели → остаёмся в review с причиной.
|
||||
func (w *Worker) Apply(ctx context.Context, id string) error {
|
||||
func (w *Worker) Apply(ctx context.Context, id string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "apply", id, err) }()
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.layouter == nil {
|
||||
@@ -233,7 +243,7 @@ func (w *Worker) Apply(ctx context.Context, id string) error {
|
||||
}
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
||||
|
||||
plan, tag, err := w.effectivePlan(ctx, id)
|
||||
plan, prov, pid, err := w.effectivePlan(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply: %w", err)
|
||||
}
|
||||
@@ -253,20 +263,41 @@ func (w *Worker) Apply(ctx context.Context, id string) error {
|
||||
return fmt.Errorf("apply: торрент ещё качается: %w", ErrNotReady)
|
||||
}
|
||||
|
||||
w.transition(ctx, *d, store.StateLinking, "", "")
|
||||
if err := w.linkPlan(ctx, d, plan, tag, translatePath(t.SavePath, w.cfg.PathMap)); err != nil {
|
||||
// Claim перехода в linking ОБЯЗАН закоммититься до создания хардлинков: при
|
||||
// провале записи не линкуем (иначе ссылки лягут при задаче в review, а
|
||||
// финальный linking→done граф отклонит — MINOR-7). Задача остаётся в
|
||||
// review/deferred, повтор безопасен. Правило сходимости папки (и уход в
|
||||
// review при рассинхроне) — уже внутри linkPlan, из состояния linking.
|
||||
if err := w.transitionErr(ctx, *d, store.StateLinking, "", ""); err != nil {
|
||||
return fmt.Errorf("apply: %w", err)
|
||||
}
|
||||
if err := w.linkPlan(ctx, d, plan, prov, pid, translatePath(t.SavePath, w.cfg.PathMap)); err != nil {
|
||||
return fmt.Errorf("apply: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// linkPlan строит и создаёт хардлинки по плану, фиксирует батч ссылок и
|
||||
// двигает задачу: done при успехе, review при коллизии/невалидном плане,
|
||||
// failed при иной ошибке ФС. Идемпотентен (повтор доводит начатое). Под mu.
|
||||
func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize.Plan, providerTag, savePath string) error {
|
||||
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag))
|
||||
// двигает задачу: done при успехе, review при коллизии/невалидном плане/
|
||||
// рассинхроне папок, failed при иной ошибке ФС. Идемпотентен (повтор доводит
|
||||
// начатое). Под mu; вызывается уже в состоянии linking (claim закоммичен) —
|
||||
// поэтому уход в review здесь легален (linking→review), как для коллизии.
|
||||
func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize.Plan, provider, providerID, savePath string) error {
|
||||
// Правило сходимости папки: при подтверждённом матче база наследуется от
|
||||
// живого якоря; рассинхрон (несколько разных живых папок) → review.
|
||||
folderBase, desync, err := w.resolveFolderBase(ctx, d.ID, provider, providerID, layout.MediaType(plan.Type))
|
||||
if err != nil {
|
||||
w.transition(ctx, *d, store.StateReview, "build", err.Error())
|
||||
w.transition(ctx, *d, store.StateReview, reasonResolve, err.Error())
|
||||
return fmt.Errorf("link plan: %w", err)
|
||||
}
|
||||
if desync {
|
||||
w.transition(ctx, *d, store.StateReview, reasonTitleFolderDesync, "несколько живых папок тайтла с одним матчем")
|
||||
return fmt.Errorf("рассинхрон папок тайтла: %w", ErrConflict)
|
||||
}
|
||||
|
||||
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag(provider, providerID), folderBase))
|
||||
if err != nil {
|
||||
w.transition(ctx, *d, store.StateReview, reasonBuild, err.Error())
|
||||
return fmt.Errorf("build links: %w", err)
|
||||
}
|
||||
|
||||
@@ -288,6 +319,12 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
}
|
||||
if len(fl) > 0 {
|
||||
if err := w.store.CreateFileLinks(ctx, fl); err != nil {
|
||||
// Хардлинки уже на диске, но их учёт не записан (транзиентная ошибка
|
||||
// SQLite). НЕ оставляем задачу в linking (осиротела бы до sweep, а
|
||||
// файлы висели бы без file_link — MAJOR-4): уводим в review с
|
||||
// причиной. Повторный Apply идемпотентен — Apply вернёт StatusExists
|
||||
// на уже созданных ссылках и допишет учёт.
|
||||
w.transition(ctx, *d, store.StateReview, reasonPersist, err.Error())
|
||||
return fmt.Errorf("persist links: %w", err)
|
||||
}
|
||||
// Инвариант «один целевой путь — один владелец»: забираем владение
|
||||
@@ -310,7 +347,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
|
||||
if applyErr != nil {
|
||||
if errors.Is(applyErr, layout.ErrCollision) {
|
||||
w.transition(ctx, *d, store.StateReview, "collision", applyErr.Error())
|
||||
w.transition(ctx, *d, store.StateReview, reasonCollision, applyErr.Error())
|
||||
return applyErr
|
||||
}
|
||||
w.transition(ctx, *d, store.StateFailed, "apply", applyErr.Error())
|
||||
@@ -327,7 +364,8 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
|
||||
// перепривязка всегда проходит через ревью с подтверждением (force_review).
|
||||
// Источник (раздача в qBittorrent) для этого должен быть на месте и докачан.
|
||||
func (w *Worker) Relink(ctx context.Context, id string) error {
|
||||
func (w *Worker) Relink(ctx context.Context, id string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "relink", id, err) }()
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -357,14 +395,15 @@ func (w *Worker) Relink(ctx context.Context, id string) error {
|
||||
}
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
logctx.From(ctx).Info("relink re-recognizing", "from", d.State)
|
||||
logctx.From(ctx).Info("state transition", "from", d.State, "to", store.StateRecognizing)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rerecognize перезапускает распознавание для задачи в review/deferred без
|
||||
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
|
||||
// цикл проведёт задачу recognizing → review заново.
|
||||
func (w *Worker) Rerecognize(ctx context.Context, id string) error {
|
||||
func (w *Worker) Rerecognize(ctx context.Context, id string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "rerecognize", id, err) }()
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -382,10 +421,11 @@ func (w *Worker) Rerecognize(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
// Refine добавляет подсказку и отправляет задачу на перераспознавание.
|
||||
func (w *Worker) Refine(ctx context.Context, id string, hint string) error {
|
||||
func (w *Worker) Refine(ctx context.Context, id string, hint string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "refine", id, err) }()
|
||||
hint = strings.TrimSpace(hint)
|
||||
if hint == "" {
|
||||
return fmt.Errorf("refine: empty hint")
|
||||
return fmt.Errorf("refine: empty hint: %w", ErrInvalidInput)
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -408,9 +448,10 @@ func (w *Worker) Refine(ctx context.Context, id string, hint string) error {
|
||||
|
||||
// SetType фиксирует тип (override) и перезапускает распознавание с подсказкой
|
||||
// — чтобы LLM пересобрал роли файлов под новый тип.
|
||||
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) error {
|
||||
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "set_type", id, err) }()
|
||||
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
|
||||
return fmt.Errorf("set type: invalid type %q", mediaType)
|
||||
return fmt.Errorf("set type: invalid type %q: %w", mediaType, ErrInvalidInput)
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -439,10 +480,11 @@ func (w *Worker) SetType(ctx context.Context, id string, mediaType string) error
|
||||
|
||||
// IgnoreFile помечает файл к игнорированию (не линкуем). Остаёмся в review;
|
||||
// превью пересчитается с учётом правки.
|
||||
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) error {
|
||||
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "ignore_file", id, err) }()
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return fmt.Errorf("ignore: empty path")
|
||||
return fmt.Errorf("ignore: empty path: %w", ErrInvalidInput)
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -468,7 +510,8 @@ func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) error {
|
||||
}
|
||||
|
||||
// Defer паркует задачу в deferred (вернётся в ревью по действию).
|
||||
func (w *Worker) Defer(ctx context.Context, id string) error {
|
||||
func (w *Worker) Defer(ctx context.Context, id string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "defer", id, err) }()
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -477,7 +520,7 @@ func (w *Worker) Defer(ctx context.Context, id string) error {
|
||||
return fmt.Errorf("defer: %w", err)
|
||||
}
|
||||
if d.State.IsTerminal() {
|
||||
return fmt.Errorf("defer: download %s is terminal (%s)", id, d.State)
|
||||
return fmt.Errorf("defer: download %s is terminal (%s): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
w.transition(ctx, *d, store.StateDeferred, "", "")
|
||||
@@ -488,7 +531,8 @@ func (w *Worker) Defer(ctx context.Context, id string) error {
|
||||
// Источник недосягаем (раскладчик удаляет только пути под библиотекой). Откат
|
||||
// снимает ЛИШНИЙ хардлинк, а не последнюю копию: layout.Undo отказывается
|
||||
// удалять ссылку, если источник уже пропал (nlink<=1) — см. state-reconciliation.
|
||||
func (w *Worker) Undo(ctx context.Context, id string) error {
|
||||
func (w *Worker) Undo(ctx context.Context, id string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "undo", id, err) }()
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.layouter == nil {
|
||||
@@ -513,23 +557,13 @@ func (w *Worker) Undo(ctx context.Context, id string) error {
|
||||
return fmt.Errorf("undo: %w", err)
|
||||
}
|
||||
if batch == "" {
|
||||
return fmt.Errorf("undo: nothing to revert")
|
||||
return fmt.Errorf("undo: nothing to revert: %w", ErrConflict)
|
||||
}
|
||||
rows, err := w.store.ListFileLinksByBatch(ctx, batch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("undo: %w", err)
|
||||
}
|
||||
// Снимаем только реально разложенные нами ссылки. superseded-строки —
|
||||
// путь забрала другая загрузка (см. state-reconciliation, владение
|
||||
// путём); файл по нему теперь её хардлинк, трогать его нельзя.
|
||||
links := make([]layout.Link, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if !isLaidOut(r.Status) {
|
||||
continue
|
||||
}
|
||||
links = append(links, layout.Link{Src: r.SrcPath, Dst: r.DstPath, Kind: layout.Kind(r.Kind)})
|
||||
}
|
||||
n, err := w.layouter.Undo(ctx, links)
|
||||
n, err := w.layouter.Undo(ctx, laidOutLinks(rows))
|
||||
if err != nil {
|
||||
return fmt.Errorf("undo: %w", err)
|
||||
}
|
||||
@@ -541,6 +575,91 @@ func (w *Worker) Undo(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// laidOutLinks отбирает реально разложенные нами ссылки батча и маппит их в
|
||||
// layout.Link. superseded-строки пропускаем: путь забрала другая загрузка (см.
|
||||
// state-reconciliation, владение путём), файл по нему — её хардлинк, трогать
|
||||
// нельзя. Общий для Undo (снимает с гардом) и Delete (снимает без гарда).
|
||||
func laidOutLinks(rows []store.FileLink) []layout.Link {
|
||||
links := make([]layout.Link, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if !isLaidOut(r.Status) {
|
||||
continue
|
||||
}
|
||||
links = append(links, layout.Link{Src: r.SrcPath, Dst: r.DstPath, Kind: layout.Kind(r.Kind)})
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
// Delete — пользовательское удаление загрузки: снять наши библиотечные ссылки
|
||||
// (гард последней копии ВЫКЛЮЧЕН, в отличие от Undo) И снести раздачу с файлами
|
||||
// из qBittorrent, переведя задачу в терминальный deleted. Осознанный выход за
|
||||
// инвариант «источник неприкосновенен» — вызывается только после подтверждения
|
||||
// в транспорте. Доступно из done/orphaned/target_missing; идемпотентно к
|
||||
// отсутствующей стороне. Source-preflight НЕ делает (цель — снять источник,
|
||||
// его отсутствие трактуем как уже снятую сторону).
|
||||
func (w *Worker) Delete(ctx context.Context, id string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "delete", id, err) }()
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.layouter == nil {
|
||||
return fmt.Errorf("delete: layouter not configured")
|
||||
}
|
||||
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
switch d.State {
|
||||
case store.StateDone, store.StateOrphaned, store.StateTargetMissing:
|
||||
default:
|
||||
return fmt.Errorf("delete: download %s is in state %s (expected done/orphaned/target_missing): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
||||
|
||||
// (а) Снять цель — наши библиотечные ссылки последнего батча БЕЗ гарда
|
||||
// последней копии. superseded пропускаем: путь забрала другая загрузка (см.
|
||||
// state-reconciliation, владение путём), её хардлинк трогать нельзя. В
|
||||
// target_missing / после ручного удаления живых ссылок нет — снятие
|
||||
// идемпотентно.
|
||||
batch, err := w.store.LatestBatchID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
removed := 0
|
||||
if batch != "" {
|
||||
rows, err := w.store.ListFileLinksByBatch(ctx, batch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
removed, err = w.layouter.Remove(ctx, laidOutLinks(rows))
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
if err := w.store.DeleteFileLinksByBatch(ctx, batch); err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// (б) Снять источник — раздачу с файлами из qBittorrent. Идемпотентно:
|
||||
// отсутствие раздачи (orphaned) не ошибка — qBit не найдёт хеш и ответит OK.
|
||||
// Реальную ошибку API пробрасываем и в deleted НЕ уводим: не заявляем
|
||||
// освобождённое место, которого не произошло (цель уже снята → сверка
|
||||
// приведёт запись к target_missing; повторный delete идемпотентно дожмёт).
|
||||
if hashes := d.HashList(); len(hashes) > 0 {
|
||||
if err := w.qbt.Delete(ctx, hashes, true); err != nil {
|
||||
return fmt.Errorf("delete: qbittorrent: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// (в) Терминальный deleted с пользовательским маркером инициатора
|
||||
// (отличает от reconcile-deleted, который кладёт "reconcile").
|
||||
w.transition(ctx, *d, store.StateDeleted, "user_delete", "удалено пользователем")
|
||||
// Запись физического эффекта (снятые ссылки) сверх перехода: from/code уже в
|
||||
// каноническом `state transition` выше — здесь только отличительное поле.
|
||||
logctx.From(ctx).Info("download deleted by user", "removed_links", removed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireReviewable проверяет, что задача в review/deferred. Вызывается под mu.
|
||||
func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*store.Download, error) {
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
@@ -558,7 +677,8 @@ func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*
|
||||
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
|
||||
// id, каноническое имя/год). Раскладку не запускает — превью обновится, а
|
||||
// человек подтвердит «Применить».
|
||||
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) error {
|
||||
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "choose_candidate", id, err) }()
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -575,7 +695,7 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) er
|
||||
return fmt.Errorf("choose candidate: %w", err)
|
||||
}
|
||||
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
|
||||
return fmt.Errorf("choose candidate: candidate %s does not belong to the current recognition", candidateID)
|
||||
return fmt.Errorf("choose candidate: candidate %s does not belong to the current recognition: %w", candidateID, ErrInvalidInput)
|
||||
}
|
||||
return w.chooseCandidateLocked(ctx, id, d, rec, *cand)
|
||||
}
|
||||
@@ -583,16 +703,17 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) er
|
||||
// AddManualSource добавляет источник вручную по (provider, id) и выбирает его.
|
||||
// Когда автопоиск промахнулся: сохраняем кандидата (дедуп по provider:id) и
|
||||
// пиннит как выбранный. provider — из набора tmdb/tvdb/imdb.
|
||||
func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID string) error {
|
||||
func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "add_manual_source", id, err) }()
|
||||
provider = strings.TrimSpace(strings.ToLower(provider))
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
switch provider {
|
||||
case "tmdb", "tvdb", "imdb":
|
||||
default:
|
||||
return fmt.Errorf("add source: invalid provider %q (tmdb/tvdb/imdb)", provider)
|
||||
return fmt.Errorf("add source: invalid provider %q (tmdb/tvdb/imdb): %w", provider, ErrInvalidInput)
|
||||
}
|
||||
if providerID == "" {
|
||||
return fmt.Errorf("add source: empty id")
|
||||
return fmt.Errorf("add source: empty id: %w", ErrInvalidInput)
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -674,20 +795,28 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
|
||||
"provider", cand.Provider, "provider_id", cand.ProviderID)
|
||||
// Подтверждённый матч — переливаем каноническое имя в display_name и в ярлык
|
||||
// раздачи (best-effort, косметика). Сбой обновления имени не должен ронять
|
||||
// выбор кандидата: логируем и продолжаем.
|
||||
if err := w.refreshDisplayNameLocked(ctx, id); err != nil {
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).
|
||||
Warn("display name refresh after candidate choice failed", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProviderID пиннит провайдера и id вручную (без выбора из списка).
|
||||
func (w *Worker) SetProviderID(ctx context.Context, id string, provider, providerID string) error {
|
||||
func (w *Worker) SetProviderID(ctx context.Context, id string, provider, providerID string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "set_provider_id", id, err) }()
|
||||
provider = strings.TrimSpace(strings.ToLower(provider))
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
switch provider {
|
||||
case "tmdb", "tvdb", "imdb":
|
||||
default:
|
||||
return fmt.Errorf("set provider: invalid provider %q (tmdb/tvdb/imdb)", provider)
|
||||
return fmt.Errorf("set provider: invalid provider %q (tmdb/tvdb/imdb): %w", provider, ErrInvalidInput)
|
||||
}
|
||||
if providerID == "" {
|
||||
return fmt.Errorf("set provider: empty id")
|
||||
return fmt.Errorf("set provider: empty id: %w", ErrInvalidInput)
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -710,7 +839,8 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
|
||||
|
||||
// ClearProvider — «без базы»: снимает матч (тег папки не ставится) и очищает
|
||||
// пины названия/года (источник — распознавание нейронкой).
|
||||
func (w *Worker) ClearProvider(ctx context.Context, id string) error {
|
||||
func (w *Worker) ClearProvider(ctx context.Context, id string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "clear_provider", id, err) }()
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -825,17 +955,25 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
|
||||
rd.Plan = applyOverrides(rawPlan, overrides)
|
||||
// Превью активного источника строим по относительным путям с
|
||||
// provider-тегом; ошибку логируем на Debug — покажем без превью.
|
||||
// База наследуется тем же правилом сходимости, что и применение
|
||||
// (превью=применение); рассинхрон в превью не переводит в review.
|
||||
if w.layouter != nil {
|
||||
tag := providerTag(prov, pid)
|
||||
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(rd.Plan, "", tag)); lerr == nil {
|
||||
base, _, berr := w.resolveFolderBase(ctx, id, prov, pid, layout.MediaType(rd.Plan.Type))
|
||||
if berr != nil {
|
||||
log.Debug("review data resolve folder base failed", "error", berr)
|
||||
}
|
||||
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(rd.Plan, "", tag, base)); lerr == nil {
|
||||
rd.Preview = links
|
||||
} else {
|
||||
log.Debug("review data build preview failed", "error", lerr)
|
||||
// Видимая деградация: без превью на экране ревью пропадает
|
||||
// кнопка «Применить» — не рядовой Debug, а WARN.
|
||||
log.Warn("review data build preview failed", "error", lerr)
|
||||
}
|
||||
}
|
||||
// Единый список источников: нейронка + кандидаты, каждый с
|
||||
// эфемерным превью из сырого плана (без записи overrides).
|
||||
rd.Sources = w.buildSources(rawPlan, overrides, prov, pid, rd.Candidates)
|
||||
rd.Sources = w.buildSources(ctx, id, rawPlan, overrides, prov, pid, rd.Candidates)
|
||||
}
|
||||
}
|
||||
return rd, nil
|
||||
@@ -844,10 +982,10 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
|
||||
// buildSources собирает единый список источников: нейронка (первой) +
|
||||
// кандидаты (дедуп по provider:id). Активным помечается текущий эффективный
|
||||
// источник.
|
||||
func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]string, prov, pid string, cands []store.MetadataCandidate) []SourceOption {
|
||||
func (w *Worker) buildSources(ctx context.Context, downloadID string, rawPlan recognize.Plan, overrides map[string]string, prov, pid string, cands []store.MetadataCandidate) []SourceOption {
|
||||
neutral := prov == "" || prov == "none"
|
||||
out := make([]SourceOption, 0, len(cands)+1)
|
||||
out = append(out, w.sourceOption(SourceNeural, rawPlan, overrides, "", "none", "", "", "", 0, neutral))
|
||||
out = append(out, w.sourceOption(ctx, downloadID, SourceNeural, rawPlan, overrides, "", "none", "", "", "", 0, neutral))
|
||||
seen := map[string]bool{}
|
||||
for _, c := range cands {
|
||||
key := c.Provider + ":" + c.ProviderID
|
||||
@@ -864,7 +1002,7 @@ func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]strin
|
||||
year = int(c.Year.Int64)
|
||||
}
|
||||
active := !neutral && c.Provider == prov && c.ProviderID == pid
|
||||
out = append(out, w.sourceOption(SourceCandidate, rawPlan, overrides, c.ID, c.Provider, c.ProviderID, c.URL.String, title, year, active))
|
||||
out = append(out, w.sourceOption(ctx, downloadID, SourceCandidate, rawPlan, overrides, c.ID, c.Provider, c.ProviderID, c.URL.String, title, year, active))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -872,7 +1010,7 @@ func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]strin
|
||||
// sourceOption строит один источник: накладывает его пины на неисточниковые
|
||||
// overrides, считает эффективный план и предпросмотр путей — эфемерно, без
|
||||
// записи. Гарантия preview == apply: тот же набор пинов запишет выбор.
|
||||
func (w *Worker) sourceOption(kind SourceKind, rawPlan recognize.Plan, base map[string]string, candID, provider, providerID, url, title string, year int, active bool) SourceOption {
|
||||
func (w *Worker) sourceOption(ctx context.Context, downloadID string, kind SourceKind, rawPlan recognize.Plan, base map[string]string, candID, provider, providerID, url, title string, year int, active bool) SourceOption {
|
||||
eff := applyOverrides(rawPlan, mergeSourceOverrides(base, sourcePins(provider, providerID, title, year)))
|
||||
opt := SourceOption{
|
||||
Kind: kind,
|
||||
@@ -887,7 +1025,10 @@ func (w *Worker) sourceOption(kind SourceKind, rawPlan recognize.Plan, base map[
|
||||
Plan: eff,
|
||||
}
|
||||
if w.layouter != nil {
|
||||
if links, err := w.layouter.BuildLinks(toLayoutPlan(eff, "", providerTag(provider, providerID))); err == nil {
|
||||
// Превью источника наследует базу тем же правилом сходимости, что и
|
||||
// применение (превью=применение); рассинхрон в превью не переводит в review.
|
||||
folderBase, _, _ := w.resolveFolderBase(ctx, downloadID, provider, providerID, layout.MediaType(eff.Type))
|
||||
if links, err := w.layouter.BuildLinks(toLayoutPlan(eff, "", providerTag(provider, providerID), folderBase)); err == nil {
|
||||
opt.Preview = links
|
||||
}
|
||||
}
|
||||
@@ -911,25 +1052,136 @@ func mergeSourceOverrides(base, pins map[string]string) map[string]string {
|
||||
}
|
||||
|
||||
// effectivePlan загружает текущий план, применяет правки и возвращает
|
||||
// provider-тег для имени папки (под mu).
|
||||
func (w *Worker) effectivePlan(ctx context.Context, id string) (recognize.Plan, string, error) {
|
||||
// эффективные provider/provider_id (для тега папки и правила сходимости) (под mu).
|
||||
func (w *Worker) effectivePlan(ctx context.Context, id string) (plan recognize.Plan, provider, providerID string, err error) {
|
||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||
if err != nil {
|
||||
return recognize.Plan{}, "", err
|
||||
return recognize.Plan{}, "", "", err
|
||||
}
|
||||
if rec == nil || !rec.Plan.Valid {
|
||||
return recognize.Plan{}, "", fmt.Errorf("no recognition plan")
|
||||
return recognize.Plan{}, "", "", fmt.Errorf("no recognition plan")
|
||||
}
|
||||
var plan recognize.Plan
|
||||
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
|
||||
return recognize.Plan{}, "", fmt.Errorf("parse plan: %w", err)
|
||||
return recognize.Plan{}, "", "", fmt.Errorf("parse plan: %w", err)
|
||||
}
|
||||
overrides, err := w.store.ListOverrides(ctx, id)
|
||||
if err != nil {
|
||||
return recognize.Plan{}, "", err
|
||||
return recognize.Plan{}, "", "", err
|
||||
}
|
||||
prov, pid := effectiveProvider(rec, overrides)
|
||||
return applyOverrides(plan, overrides), providerTag(prov, pid), nil
|
||||
return applyOverrides(plan, overrides), prov, pid, nil
|
||||
}
|
||||
|
||||
// RefreshDisplayName — внешняя точка входа обновления отображаемого имени
|
||||
// (ручная кнопка): берёт w.mu и делегирует refreshDisplayNameLocked.
|
||||
func (w *Worker) RefreshDisplayName(ctx context.Context, id string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "refresh_name", id, err) }()
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.refreshDisplayNameLocked(ctx, id)
|
||||
}
|
||||
|
||||
// refreshDisplayNameLocked переливает уже вычисленное каноническое имя
|
||||
// (эффективный план с учётом пинов) в download.display_name и в имя раздачи
|
||||
// qBittorrent — без нового вызова LLM. Косметика: не влияет на пути/раскладку.
|
||||
// Best-effort к qBittorrent: недоступность/отсутствие раздачи не проваливает
|
||||
// операцию (display_name пишется в любом случае). Вызывается под w.mu.
|
||||
func (w *Worker) refreshDisplayNameLocked(ctx context.Context, id string) error {
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh display name: %w", err)
|
||||
}
|
||||
if d == nil {
|
||||
return fmt.Errorf("refresh display name: download %s: %w", id, store.ErrNotFound)
|
||||
}
|
||||
cctx := w.scoped(ctx, capIngest, id, d.PrimaryInfohash())
|
||||
|
||||
rec, err := w.store.GetCurrentRecognition(cctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh display name: %w", err)
|
||||
}
|
||||
if rec == nil || !rec.Plan.Valid {
|
||||
return nil // распознавания ещё нет — обновлять нечего (no-op)
|
||||
}
|
||||
plan, _, _, err := w.effectivePlan(cctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh display name: %w", err)
|
||||
}
|
||||
name := naming.FormatTitleYear(plan.Title, plan.Year)
|
||||
if name == "" {
|
||||
return nil // пустое распознанное название — no-op
|
||||
}
|
||||
if err := w.store.SetDisplayName(cctx, id, name); err != nil {
|
||||
return fmt.Errorf("refresh display name: %w", err)
|
||||
}
|
||||
logctx.From(cctx).Info("display name refreshed", "display_name", name)
|
||||
|
||||
// Переименование раздачи — best-effort по её реальному Torrent.Hash (для
|
||||
// гибрид/v2 он может не совпасть с нашим primary infohash). Отсутствие
|
||||
// раздачи (удалена) — штатный no-op: display_name уже обновлён.
|
||||
t, ok, err := w.torrentByInfohash(cctx, d.HashList())
|
||||
if err != nil {
|
||||
logctx.From(cctx).Warn("refresh display name: qbittorrent lookup failed", "error", err)
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := w.qbt.RenameTorrent(cctx, t.Hash, name); err != nil {
|
||||
logctx.From(cctx).Warn("refresh display name: qbittorrent rename failed", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Коды причины (error_code) ухода задачи в review при раскладке (linkPlan) —
|
||||
// корреляционный ключ шага, на котором раскладка остановилась. Свод в одном
|
||||
// месте (как errCode* в worker.go); человекочитаемый текст кладётся в error_msg.
|
||||
const (
|
||||
reasonResolve = "resolve" // не удалось разрешить базу папки тайтла
|
||||
reasonBuild = "build" // не удалось построить план ссылок
|
||||
reasonPersist = "persist" // ссылки на диске, но учёт не записан
|
||||
reasonCollision = "collision" // целевой путь уже занят (layout.ErrCollision)
|
||||
reasonTitleFolderDesync = "title_folder_desync" // ≥2 разных живых папок тайтла с одним матчем
|
||||
)
|
||||
|
||||
// resolveFolderBase применяет правило сходимости папки (см. file-layout spec):
|
||||
// при подтверждённом матче наследует базу имени от живой папки-якоря того же
|
||||
// (provider, providerID), кроме самой загрузки downloadID. Возвращает базу для
|
||||
// layout.Plan.FolderBase (пусто → печатать из распознавания), флаг рассинхрона
|
||||
// (≥2 разных живых папок) и ошибку. Чистая (только чтение БД+ФС), без побочных
|
||||
// эффектов — переиспользуется применением и превью. Живость якоря определяется
|
||||
// существованием папки на диске (os.Lstat), а не статусом ссылки в БД.
|
||||
func (w *Worker) resolveFolderBase(ctx context.Context, downloadID, provider, providerID string, mt layout.MediaType) (base string, desync bool, err error) {
|
||||
if provider == "" || provider == "none" || providerID == "" || w.layouter == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
paths, err := w.store.LiveTitleFolders(ctx, provider, providerID, downloadID)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("resolve folder base: %w", err)
|
||||
}
|
||||
bases := make(map[string]string, 2) // абсолютная папка тайтла → база имени
|
||||
for _, p := range paths {
|
||||
dir, b, ok := w.layouter.TitleFolder(mt, p)
|
||||
if !ok {
|
||||
continue // не под корнем / не разобрать — не якорь
|
||||
}
|
||||
if _, seen := bases[dir]; seen {
|
||||
continue
|
||||
}
|
||||
if _, serr := os.Lstat(dir); serr != nil {
|
||||
continue // папки на диске нет (переименована/удалена) — не якорь
|
||||
}
|
||||
bases[dir] = b
|
||||
}
|
||||
switch len(bases) {
|
||||
case 0:
|
||||
return "", false, nil
|
||||
case 1:
|
||||
for _, b := range bases {
|
||||
return b, false, nil
|
||||
}
|
||||
}
|
||||
return "", true, nil // рассинхрон: несколько разных живых папок
|
||||
}
|
||||
|
||||
// --- Хелперы преобразования ---
|
||||
@@ -1002,7 +1254,7 @@ func ProviderTag(provider, id string) string { return providerTag(provider, id)
|
||||
|
||||
// ToLayoutPlan — экспорт toLayoutPlan для диагностических команд.
|
||||
func ToLayoutPlan(p recognize.Plan, srcPrefix, providerTag string) layout.Plan {
|
||||
return toLayoutPlan(p, srcPrefix, providerTag)
|
||||
return toLayoutPlan(p, srcPrefix, providerTag, "")
|
||||
}
|
||||
|
||||
// providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…"
|
||||
@@ -1025,14 +1277,17 @@ func providerTag(provider, id string) string {
|
||||
|
||||
// toLayoutPlan переводит план распознавания в план раскладки. srcPrefix
|
||||
// (savePath) приклеивается к относительным путям файлов; пустой — оставляет
|
||||
// относительные (для превью). providerTag добавляется к имени папки. Роли
|
||||
// вне main/episode/subtitle отбрасываются.
|
||||
func toLayoutPlan(plan recognize.Plan, srcPrefix, providerTag string) layout.Plan {
|
||||
// относительные (для превью). providerTag добавляется к имени папки. folderBase
|
||||
// (непустой) — унаследованная от живого якоря база имени (правило сходимости):
|
||||
// перекрывает Title/Year в папке и в именах файлов. Роли вне
|
||||
// main/episode/subtitle отбрасываются.
|
||||
func toLayoutPlan(plan recognize.Plan, srcPrefix, providerTag, folderBase string) layout.Plan {
|
||||
lp := layout.Plan{
|
||||
Type: layout.MediaType(plan.Type),
|
||||
Title: plan.Title,
|
||||
Year: plan.Year,
|
||||
ProviderTag: providerTag,
|
||||
FolderBase: folderBase,
|
||||
}
|
||||
for _, f := range plan.Files {
|
||||
role, ok := mapRole(f.Role)
|
||||
@@ -1094,6 +1349,10 @@ func parseIgnored(s string) []string {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
// Ошибку разбора глотаем намеренно: битый JSON в override ignored_files
|
||||
// (не должен возникать — пишем его сами через json.Marshal) трактуем как
|
||||
// «нет игнора», а не роняем команду. Худший исход — файл не будет пропущен,
|
||||
// человек увидит его в превью и пометит заново.
|
||||
_ = json.Unmarshal([]byte(s), &out)
|
||||
return out
|
||||
}
|
||||
|
||||
+628
-11
@@ -10,6 +10,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -310,6 +311,10 @@ type memStore struct {
|
||||
links []store.FileLink
|
||||
candidates []store.MetadataCandidate
|
||||
torrents map[string][]byte
|
||||
|
||||
// Инъекция сбоев (для тестов устойчивости раскладки).
|
||||
failCreateLinks error // CreateFileLinks вернёт эту ошибку
|
||||
failSetState func(store.State) error // SetDownloadState вернёт ошибку для перехода
|
||||
}
|
||||
|
||||
func newMemStore() *memStore {
|
||||
@@ -436,6 +441,11 @@ func (m *memStore) GetDownload(_ context.Context, id string) (*store.Download, e
|
||||
}
|
||||
|
||||
func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
|
||||
if m.failSetState != nil {
|
||||
if err := m.failSetState(st); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
d := m.downloads[id]
|
||||
d.State = st
|
||||
d.ErrorCode = store.NullString(code)
|
||||
@@ -453,6 +463,13 @@ func (m *memStore) PromoteCatched(_ context.Context, id, displayName string) err
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) SetDisplayName(_ context.Context, id, name string) error {
|
||||
if d, ok := m.downloads[id]; ok {
|
||||
d.DisplayName = name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error {
|
||||
if d, ok := m.downloads[id]; ok {
|
||||
d.SourceMissCount = n
|
||||
@@ -467,6 +484,13 @@ func (m *memStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) SetRetriedAt(_ context.Context, id string, t time.Time) error {
|
||||
if d, ok := m.downloads[id]; ok {
|
||||
d.RetriedAt = store.NullString(store.FormatTime(t))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (string, error) {
|
||||
for _, e := range m.recs {
|
||||
if e.DownloadID == r.DownloadID {
|
||||
@@ -516,6 +540,9 @@ func (m *memStore) ListOverrides(_ context.Context, id string) (map[string]strin
|
||||
}
|
||||
|
||||
func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) error {
|
||||
if m.failCreateLinks != nil {
|
||||
return m.failCreateLinks
|
||||
}
|
||||
m.links = append(m.links, links...)
|
||||
return nil
|
||||
}
|
||||
@@ -538,6 +565,27 @@ func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID string, d
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *memStore) LiveTitleFolders(_ context.Context, provider, providerID, excludeDownloadID string) ([]string, error) {
|
||||
if provider == "" || providerID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
match := map[string]bool{} // download_id с current recognition этого матча
|
||||
for _, r := range m.recs {
|
||||
if r.IsCurrent && r.Provider.String == provider && r.ProviderID.String == providerID {
|
||||
match[r.DownloadID] = true
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
for _, l := range m.links {
|
||||
if l.DownloadID == excludeDownloadID || !match[l.DownloadID] {
|
||||
continue
|
||||
}
|
||||
if isLaidOut(l.Status) {
|
||||
out = append(out, l.DstPath)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (m *memStore) LatestBatchID(_ context.Context, id string) (string, error) {
|
||||
for i := len(m.links) - 1; i >= 0; i-- {
|
||||
if m.links[i].DownloadID == id {
|
||||
@@ -853,6 +901,7 @@ func TestDefer(t *testing.T) {
|
||||
type applyFixture struct {
|
||||
w *Worker
|
||||
st *memStore
|
||||
qb *fakeQbt
|
||||
downloads string
|
||||
movies string
|
||||
series string
|
||||
@@ -892,7 +941,7 @@ func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: downloads, Category: "jellybit", State: "uploading"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, lay)
|
||||
|
||||
return applyFixture{w: w, st: st, downloads: downloads, movies: movies, series: series}
|
||||
return applyFixture{w: w, st: st, qb: qb, downloads: downloads, movies: movies, series: series}
|
||||
}
|
||||
|
||||
func TestApply_LinksAndDone(t *testing.T) {
|
||||
@@ -996,6 +1045,219 @@ func TestApply_SupersedesForeignOwnerOfPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Правило сходимости папки (проблема второго сезона) ---
|
||||
|
||||
// matchCurrentRec помечает current recognition загрузки id подтверждённым
|
||||
// матчем (для правила сходимости).
|
||||
func matchCurrentRec(f applyFixture, id, provider, providerID string) {
|
||||
for _, r := range f.st.recs {
|
||||
if r.DownloadID == id && r.IsCurrent {
|
||||
r.Provider = store.NullString(provider)
|
||||
r.ProviderID = store.NullString(providerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedLiveAnchor заводит живой якорь тайтла: current recognition матча +
|
||||
// разложенную ссылку на папку folder под series, и создаёт эту папку на диске.
|
||||
func seedLiveAnchor(t *testing.T, f applyFixture, dlID, provider, providerID, folder string, onDisk bool) {
|
||||
t.Helper()
|
||||
dst := filepath.Join(f.series, folder, "Season 01", folderBaseTest(folder)+" S01E01.mkv")
|
||||
if onDisk {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dst, []byte("anchor"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
f.st.recs = append(f.st.recs, &store.Recognition{
|
||||
ID: "rec-" + dlID, DownloadID: dlID, IsCurrent: true,
|
||||
Provider: store.NullString(provider), ProviderID: store.NullString(providerID),
|
||||
})
|
||||
f.st.links = append(f.st.links, store.FileLink{
|
||||
DownloadID: dlID, ApplyBatchID: "anchor-" + dlID, SrcPath: "/anchor/e1.mkv",
|
||||
DstPath: dst, Kind: "video", Status: "linked",
|
||||
})
|
||||
}
|
||||
|
||||
// folderBaseTest — «Фарго (2014)» из «Фарго (2014) [tvdbid-269613]» (для имён
|
||||
// файлов якоря в тесте).
|
||||
func folderBaseTest(folder string) string {
|
||||
if i := strings.LastIndex(folder, " ["); i >= 0 {
|
||||
return folder[:i]
|
||||
}
|
||||
return folder
|
||||
}
|
||||
|
||||
func TestApply_FolderConvergence_SecondSeason(t *testing.T) {
|
||||
// Текущая загрузка распознана как «Show (2006)», но матч tvdb=269613
|
||||
// совпадает с живым якорем «Фарго (2014)» → наследуем его базу.
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
matchCurrentRec(f, "1", "tvdb", "269613")
|
||||
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
|
||||
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
// Второй сезон лёг в папку якоря, база — унаследованная и в имени файла.
|
||||
want := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 02", "Фарго (2014) S02E01.mkv")
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Errorf("ожидали раскладку в папку якоря %q: %v", want, err)
|
||||
}
|
||||
// Своей папки «Show (2006)» быть не должно.
|
||||
if _, err := os.Stat(filepath.Join(f.series, "Show (2006) [tvdbid-269613]")); err == nil {
|
||||
t.Error("создана отдельная папка вместо сходимости")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_FolderConvergence_NoAnchorPrintsFresh(t *testing.T) {
|
||||
// Матч есть, но живого якоря нет → печатаем базу из распознавания.
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
matchCurrentRec(f, "1", "tvdb", "269613")
|
||||
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv")
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Errorf("ожидали свежую папку из распознавания %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_FolderConvergence_ProviderMismatchNoInherit(t *testing.T) {
|
||||
// Якорь заматчен другим провайдером (tmdb) → по (tvdb,269613) не находится,
|
||||
// базу не наследуем.
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
matchCurrentRec(f, "1", "tvdb", "269613")
|
||||
seedLiveAnchor(t, f, "2", "tmdb", "60622", "Фарго (2014) [tmdbid-60622]", true)
|
||||
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv")
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Errorf("смена провайдера не должна склеивать; ожидали %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_FolderConvergence_RenamedAnchorPrintsFresh(t *testing.T) {
|
||||
// Ссылка якоря живая в БД, но папки на диске нет (переименована) → не якорь.
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
matchCurrentRec(f, "1", "tvdb", "269613")
|
||||
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", false) // onDisk=false
|
||||
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv")
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Errorf("отсутствующая на диске папка не якорь; ожидали %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_FolderConvergence_DesyncGoesReview(t *testing.T) {
|
||||
// Две разные живые папки одного матча → рассинхрон → review, без раскладки.
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
matchCurrentRec(f, "1", "tvdb", "269613")
|
||||
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
|
||||
seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true)
|
||||
|
||||
err := f.w.Apply(context.Background(), "1")
|
||||
if err == nil {
|
||||
t.Fatal("ожидали ошибку рассинхрона")
|
||||
}
|
||||
if f.st.downloads["1"].State != store.StateReview {
|
||||
t.Errorf("state = %q, want review", f.st.downloads["1"].State)
|
||||
}
|
||||
if f.st.downloads["1"].ErrorCode.String != reasonTitleFolderDesync {
|
||||
t.Errorf("error_code = %q, want %q", f.st.downloads["1"].ErrorCode.String, reasonTitleFolderDesync)
|
||||
}
|
||||
// Раскладки не было.
|
||||
if len(f.st.links) != 2 { // только два якоря
|
||||
t.Errorf("file_links = %d, раскладка не должна была выполниться", len(f.st.links))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_FolderConvergence_DesyncFromDeferredGoesReview(t *testing.T) {
|
||||
// Регрессия: apply из deferred при рассинхроне. Уход в review идёт из linking
|
||||
// (linking→review легален), а не прямым deferred→review (ребра в графе нет).
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
f.st.downloads["1"].State = store.StateDeferred
|
||||
matchCurrentRec(f, "1", "tvdb", "269613")
|
||||
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
|
||||
seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true)
|
||||
|
||||
if err := f.w.Apply(context.Background(), "1"); err == nil {
|
||||
t.Fatal("ожидали ошибку рассинхрона")
|
||||
}
|
||||
if f.st.downloads["1"].State != store.StateReview {
|
||||
t.Errorf("state = %q, want review", f.st.downloads["1"].State)
|
||||
}
|
||||
if f.st.downloads["1"].ErrorCode.String != reasonTitleFolderDesync {
|
||||
t.Errorf("error_code = %q, want %q", f.st.downloads["1"].ErrorCode.String, reasonTitleFolderDesync)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewData_PreviewMatchesConvergedApply(t *testing.T) {
|
||||
// Превью активного источника наследует ту же базу, что даст применение.
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
matchCurrentRec(f, "1", "tvdb", "269613")
|
||||
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
|
||||
|
||||
rd, err := f.w.ReviewData(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewData: %v", err)
|
||||
}
|
||||
if len(rd.Preview) == 0 {
|
||||
t.Fatal("превью пусто")
|
||||
}
|
||||
// Превью ведёт в папку якоря (относительные пути, с тегом).
|
||||
if !strings.Contains(rd.Preview[0].Dst, filepath.Join("Фарго (2014) [tvdbid-269613]", "Season 02")) {
|
||||
t.Errorf("превью = %q, ожидали папку якоря", rd.Preview[0].Dst)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_FolderConvergence_NoMatchNoInherit(t *testing.T) {
|
||||
// Матча нет (provider=none), но рядом есть живой якорь — наследования быть не
|
||||
// должно (сходимость только при подтверждённом матче).
|
||||
f := newApplyFixture(t, seriesResult().Plan) // current rec без provider
|
||||
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
|
||||
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
// Без матча тег пуст, база из распознавания.
|
||||
want := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Errorf("без матча наследования нет; ожидали %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewData_PreviewDesyncPrintsFreshNoReview(t *testing.T) {
|
||||
// Рассинхрон в превью НЕ переводит задачу в review (превью информационно):
|
||||
// база печатается из распознавания, состояние остаётся review.
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
matchCurrentRec(f, "1", "tvdb", "269613")
|
||||
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
|
||||
seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true)
|
||||
|
||||
rd, err := f.w.ReviewData(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewData: %v", err)
|
||||
}
|
||||
if f.st.downloads["1"].State != store.StateReview {
|
||||
t.Errorf("state = %q, показ превью не должен менять состояние", f.st.downloads["1"].State)
|
||||
}
|
||||
if len(rd.Preview) == 0 {
|
||||
t.Fatal("превью пусто")
|
||||
}
|
||||
// База из распознавания (не унаследованная), тег текущего матча.
|
||||
if !strings.Contains(rd.Preview[0].Dst, filepath.Join("Show (2006) [tvdbid-269613]", "Season 02")) {
|
||||
t.Errorf("превью при рассинхроне = %q, ожидали свежую базу из распознавания", rd.Preview[0].Dst)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_CollisionKeepsForeignOwner(t *testing.T) {
|
||||
// Если файл прежней загрузки ВСЁ ЕЩЁ на месте — коллизия → review, владение
|
||||
// не отбирается (supersede не срабатывает для не-разложенного пути).
|
||||
@@ -1049,6 +1311,125 @@ func TestUndo_RevertsLinks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_FromDone_RemovesBothSides(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
|
||||
|
||||
if err := f.w.Delete(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
d := f.st.downloads["1"]
|
||||
if d.State != store.StateDeleted {
|
||||
t.Errorf("state = %q, want deleted", d.State)
|
||||
}
|
||||
if d.ErrorCode.String != "user_delete" {
|
||||
t.Errorf("error_code = %q, want user_delete", d.ErrorCode.String)
|
||||
}
|
||||
if _, err := os.Stat(dst); !os.IsNotExist(err) {
|
||||
t.Errorf("library link must be removed: %v", err)
|
||||
}
|
||||
if len(f.st.links) != 0 {
|
||||
t.Errorf("file_links must be deleted, got %d", len(f.st.links))
|
||||
}
|
||||
if len(f.qb.deleted) != 1 || len(f.qb.deleted[0]) == 0 || f.qb.deleted[0][0] != ihTest {
|
||||
t.Errorf("qbt.Delete calls = %v, want [[%s]]", f.qb.deleted, ihTest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_FromOrphaned_RemovesLastCopy(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
|
||||
// Источник пропал → библиотечная ссылка стала последней копией (nlink==1):
|
||||
// Undo бы отказал, Delete снимает осознанно.
|
||||
for _, src := range []string{"Show/e1.mkv", "Show/e2.mkv"} {
|
||||
_ = os.Remove(filepath.Join(f.downloads, src))
|
||||
}
|
||||
f.qb.torrents = nil // раздачи в qBittorrent больше нет
|
||||
f.st.downloads["1"].State = store.StateOrphaned
|
||||
|
||||
if err := f.w.Delete(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if f.st.downloads["1"].State != store.StateDeleted {
|
||||
t.Errorf("state = %q, want deleted", f.st.downloads["1"].State)
|
||||
}
|
||||
if _, err := os.Stat(dst); !os.IsNotExist(err) {
|
||||
t.Errorf("last-copy link must be removed despite guard: %v", err)
|
||||
}
|
||||
// Отсутствие раздачи не ошибка — вызов всё равно состоялся идемпотентно.
|
||||
if len(f.qb.deleted) != 1 {
|
||||
t.Errorf("qbt.Delete calls = %d, want 1 (idempotent)", len(f.qb.deleted))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_FromTargetMissing_Idempotent(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
// Цель удалена вручную, источник на месте.
|
||||
dst1 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
|
||||
dst2 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E02.mkv")
|
||||
_ = os.Remove(dst1)
|
||||
_ = os.Remove(dst2)
|
||||
f.st.downloads["1"].State = store.StateTargetMissing
|
||||
|
||||
if err := f.w.Delete(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if f.st.downloads["1"].State != store.StateDeleted {
|
||||
t.Errorf("state = %q, want deleted", f.st.downloads["1"].State)
|
||||
}
|
||||
if len(f.qb.deleted) != 1 { // остаточную раздачу снесли
|
||||
t.Errorf("qbt.Delete calls = %d, want 1", len(f.qb.deleted))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_WrongStateRejected(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan) // состояние review
|
||||
err := f.w.Delete(context.Background(), "1")
|
||||
if !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("Delete from review = %v, want ErrConflict", err)
|
||||
}
|
||||
if f.st.downloads["1"].State != store.StateReview {
|
||||
t.Errorf("state = %q, want review (unchanged)", f.st.downloads["1"].State)
|
||||
}
|
||||
if len(f.qb.deleted) != 0 {
|
||||
t.Errorf("qbt.Delete must not be called, got %v", f.qb.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_QbtErrorKeepsState(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
f.qb.deleteErr = errors.New("qbittorrent boom")
|
||||
|
||||
if err := f.w.Delete(context.Background(), "1"); err == nil {
|
||||
t.Fatal("Delete must return qbittorrent error")
|
||||
}
|
||||
// Ложно в deleted не уходим (место не освобождено).
|
||||
if f.st.downloads["1"].State == store.StateDeleted {
|
||||
t.Errorf("state = deleted despite qbt error; want unchanged (done)")
|
||||
}
|
||||
// Повторный delete после устранения сбоя идемпотентно дожимает удаление:
|
||||
// цель уже снята (file_links удалены), источник сносится, задача → deleted.
|
||||
f.qb.deleteErr = nil
|
||||
if err := f.w.Delete(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("repeat Delete: %v", err)
|
||||
}
|
||||
if f.st.downloads["1"].State != store.StateDeleted {
|
||||
t.Errorf("state = %q after repeat, want deleted", f.st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewData(t *testing.T) {
|
||||
plan := seriesResult().Plan
|
||||
f := newApplyFixture(t, plan)
|
||||
@@ -1235,18 +1616,154 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
|
||||
t.Error("кандидат не помечен выбранным")
|
||||
}
|
||||
// Эффективный план берёт каноническое имя/год и тег [tvdbid-...].
|
||||
plan, tag, err := w.effectivePlan(context.Background(), "1")
|
||||
plan, prov, pid, err := w.effectivePlan(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("effectivePlan: %v", err)
|
||||
}
|
||||
if plan.Title != "Fargo" || plan.Year != 2014 {
|
||||
t.Errorf("plan = %q (%d)", plan.Title, plan.Year)
|
||||
}
|
||||
if tag != "tvdbid-269613" {
|
||||
if tag := providerTag(prov, pid); tag != "tvdbid-269613" {
|
||||
t.Errorf("tag = %q", tag)
|
||||
}
|
||||
}
|
||||
|
||||
// refreshSetup: загрузка в review с распознаванием (movie «Догадка»/2000),
|
||||
// display_name="Unknown", кандидатом cand и раздачей в qBit по ihTest — для
|
||||
// проверок перелива имени после подтверждения матча.
|
||||
func refreshSetup(t *testing.T, cand store.MetadataCandidate) (*Worker, *memStore, *fakeQbt) {
|
||||
t.Helper()
|
||||
st := newMemStore()
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
d.DisplayName = "Unknown"
|
||||
st.put(d)
|
||||
planJSON, _ := json.Marshal(recognize.Plan{Type: recognize.MediaMovie, Title: "Догадка", Year: 2000})
|
||||
st.recs = append(st.recs, &store.Recognition{
|
||||
ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
|
||||
Provider: store.NullString("none"),
|
||||
})
|
||||
cand.RecognitionID = "1"
|
||||
_ = st.CreateCandidates(context.Background(), []store.MetadataCandidate{cand})
|
||||
// Ключ раздачи в qBit (Hash) НАМЕРЕННО отличается от нашего инфохэша: matched
|
||||
// по InfohashV1. Так тест ловит, что rename идёт по реальному t.Hash (мотив
|
||||
// D3: для гибрид/v2 t.Hash ≠ PrimaryInfohash), а не по сырому инфохэшу.
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: qbtHash, InfohashV1: ihTest, Name: "Unknown"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
|
||||
return w, st, qb
|
||||
}
|
||||
|
||||
// qbtHash — внутренний ключ раздачи в qBittorrent, отличный от инфохэша загрузки.
|
||||
const qbtHash = "qbt-internal-key"
|
||||
|
||||
func TestChooseCandidate_RefreshesDisplayName(t *testing.T) {
|
||||
w, st, qb := refreshSetup(t, store.MetadataCandidate{
|
||||
Provider: "tvdb", ProviderID: "343558",
|
||||
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
|
||||
})
|
||||
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
|
||||
t.Fatalf("ChooseCandidate: %v", err)
|
||||
}
|
||||
const want = "Harold and the Purple Crayon (2024)"
|
||||
if got := st.downloads["1"].DisplayName; got != want {
|
||||
t.Errorf("display_name = %q, want %q", got, want)
|
||||
}
|
||||
// rename адресован по реальному t.Hash (qbtHash), а НЕ по инфохэшу загрузки.
|
||||
if len(qb.renamed) != 1 || qb.renamed[0].hash != qbtHash || qb.renamed[0].name != want {
|
||||
t.Errorf("renamed = %+v, want hash=%s name=%q", qb.renamed, qbtHash, want)
|
||||
}
|
||||
if qbtHash == ihTest {
|
||||
t.Fatal("тест бессмыслен: qbtHash совпал с инфохэшем загрузки")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseCandidate_RefreshDisplayName_QbitErrorTolerated(t *testing.T) {
|
||||
w, st, qb := refreshSetup(t, store.MetadataCandidate{
|
||||
Provider: "tvdb", ProviderID: "343558",
|
||||
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
|
||||
})
|
||||
qb.renameErr = errors.New("qbit down")
|
||||
// Best-effort: сбой переименования раздачи не должен ронять выбор кандидата.
|
||||
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
|
||||
t.Fatalf("ChooseCandidate must tolerate qbit rename failure: %v", err)
|
||||
}
|
||||
if got := st.downloads["1"].DisplayName; got != "Harold and the Purple Crayon (2024)" {
|
||||
t.Errorf("display_name = %q (должен быть обновлён несмотря на сбой qBit)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseCandidate_RefreshDisplayName_TorrentMissing(t *testing.T) {
|
||||
// Раздача удалена (нет в qBit) — display_name всё равно обновлён, rename нет.
|
||||
w, st, qb := refreshSetup(t, store.MetadataCandidate{
|
||||
Provider: "tvdb", ProviderID: "343558",
|
||||
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
|
||||
})
|
||||
qb.torrents = nil // torrentByInfohash не найдёт раздачу → best-effort no-op
|
||||
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
|
||||
t.Fatalf("ChooseCandidate: %v", err)
|
||||
}
|
||||
if got := st.downloads["1"].DisplayName; got != "Harold and the Purple Crayon (2024)" {
|
||||
t.Errorf("display_name = %q (должен быть обновлён и без раздачи)", got)
|
||||
}
|
||||
if len(qb.renamed) != 0 {
|
||||
t.Errorf("renamed = %+v, want none (раздачи нет)", qb.renamed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseCandidate_RefreshDisplayName_TorrentsLookupError(t *testing.T) {
|
||||
// Листинг qBit упал — display_name обновлён, rename не зовём, команда цела.
|
||||
w, st, qb := refreshSetup(t, store.MetadataCandidate{
|
||||
Provider: "tvdb", ProviderID: "343558",
|
||||
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
|
||||
})
|
||||
qb.torrentsErr = errors.New("qbit down")
|
||||
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
|
||||
t.Fatalf("ChooseCandidate must tolerate qbit lookup failure: %v", err)
|
||||
}
|
||||
if got := st.downloads["1"].DisplayName; got != "Harold and the Purple Crayon (2024)" {
|
||||
t.Errorf("display_name = %q (должен быть обновлён несмотря на сбой листинга)", got)
|
||||
}
|
||||
if len(qb.renamed) != 0 {
|
||||
t.Errorf("renamed = %+v, want none (листинг упал)", qb.renamed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddManualSource_RefreshesDisplayName(t *testing.T) {
|
||||
// Ручное добавление источника — тоже подтверждение матча → refresh.
|
||||
// Кандидат без title/year → имя берётся из распознавания (fallback).
|
||||
w, st, qb := refreshSetup(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "269613"})
|
||||
if err := w.AddManualSource(context.Background(), "1", "tmdb", "603"); err != nil {
|
||||
t.Fatalf("AddManualSource: %v", err)
|
||||
}
|
||||
if got := st.downloads["1"].DisplayName; got != "Догадка (2000)" {
|
||||
t.Errorf("display_name = %q, want fallback «Догадка (2000)»", got)
|
||||
}
|
||||
if len(qb.renamed) != 1 || qb.renamed[0].name != "Догадка (2000)" {
|
||||
t.Errorf("renamed = %+v", qb.renamed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshDisplayName_NoRecognitionNoop(t *testing.T) {
|
||||
// Нет распознавания — обновлять нечего: display_name не трогаем, rename нет.
|
||||
st := newMemStore()
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateDownloading
|
||||
d.DisplayName = ""
|
||||
st.put(d)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "raw"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.RefreshDisplayName(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("RefreshDisplayName: %v", err)
|
||||
}
|
||||
if got := st.downloads["1"].DisplayName; got != "" {
|
||||
t.Errorf("display_name = %q, want empty (no-op)", got)
|
||||
}
|
||||
if len(qb.renamed) != 0 {
|
||||
t.Errorf("renamed = %+v, want none", qb.renamed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseCandidate_RejectsForeign(t *testing.T) {
|
||||
w, _ := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
|
||||
if err := w.ChooseCandidate(context.Background(), "1", "999"); err == nil {
|
||||
@@ -1280,8 +1797,8 @@ func TestClearProvider(t *testing.T) {
|
||||
t.Errorf("provider override = %q, want none", st.overrides["1"][ovrProvider])
|
||||
}
|
||||
// «Без базы» → пустой тег.
|
||||
_, tag, _ := w.effectivePlan(context.Background(), "1")
|
||||
if tag != "" {
|
||||
_, prov, pid, _ := w.effectivePlan(context.Background(), "1")
|
||||
if tag := providerTag(prov, pid); tag != "" {
|
||||
t.Errorf("tag = %q, want empty", tag)
|
||||
}
|
||||
}
|
||||
@@ -1302,14 +1819,14 @@ func TestClearProvider_ClearsInheritedTitle(t *testing.T) {
|
||||
t.Fatalf("ClearProvider: %v", err)
|
||||
}
|
||||
// Название/год снова из плана распознавания, тег пуст.
|
||||
plan, tag, err := w.effectivePlan(context.Background(), "1")
|
||||
plan, prov, pid, err := w.effectivePlan(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("effectivePlan: %v", err)
|
||||
}
|
||||
if plan.Title != "Догадка" || plan.Year != 2000 {
|
||||
t.Errorf("plan = %q (%d), want plan-defaults", plan.Title, plan.Year)
|
||||
}
|
||||
if tag != "" {
|
||||
if tag := providerTag(prov, pid); tag != "" {
|
||||
t.Errorf("tag = %q, want empty", tag)
|
||||
}
|
||||
if ov := st.overrides["1"]; ov[ovrTitle] != "" || ov[ovrYear] != "" {
|
||||
@@ -1333,14 +1850,14 @@ func TestAddManualSource_ChoosesAndClears(t *testing.T) {
|
||||
if len(st.candidates) != 2 {
|
||||
t.Fatalf("candidates = %d, want 2 (manual added)", len(st.candidates))
|
||||
}
|
||||
plan, tag, err := w.effectivePlan(context.Background(), "1")
|
||||
plan, prov, pid, err := w.effectivePlan(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("effectivePlan: %v", err)
|
||||
}
|
||||
if plan.Title != "Догадка" { // безтайтловый источник → название из плана
|
||||
t.Errorf("plan title = %q, want plan-default", plan.Title)
|
||||
}
|
||||
if tag != "tmdbid-603" {
|
||||
if tag := providerTag(prov, pid); tag != "tmdbid-603" {
|
||||
t.Errorf("tag = %q", tag)
|
||||
}
|
||||
}
|
||||
@@ -1488,7 +2005,7 @@ func TestToLayoutPlan(t *testing.T) {
|
||||
{Src: "sample.mkv", Role: "sample"},
|
||||
},
|
||||
}
|
||||
lp := toLayoutPlan(plan, "/d", "tmdbid-1")
|
||||
lp := toLayoutPlan(plan, "/d", "tmdbid-1", "")
|
||||
if len(lp.Files) != 1 {
|
||||
t.Fatalf("want 1 linkable file, got %d", len(lp.Files))
|
||||
}
|
||||
@@ -1530,7 +2047,7 @@ func TestToLayoutPlan_SrcPrefixIsSavePath(t *testing.T) {
|
||||
{Src: tc.src, Role: recognize.RoleMain, Season: &s, Episode: &e},
|
||||
},
|
||||
}
|
||||
lp := toLayoutPlan(plan, savePath, "")
|
||||
lp := toLayoutPlan(plan, savePath, "", "")
|
||||
if len(lp.Files) != 1 {
|
||||
t.Fatalf("want 1 file, got %d", len(lp.Files))
|
||||
}
|
||||
@@ -1540,3 +2057,103 @@ func TestToLayoutPlan_SrcPrefixIsSavePath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Устойчивость раскладки и переходов (MAJOR-4, MINOR-7) ---
|
||||
|
||||
// TestApply_ClaimFailureAbortsBeforeLinks (MINOR-7): если запись claim перехода
|
||||
// в linking падает, Apply ОБЯЗАН прерваться ДО создания хардлинков — иначе
|
||||
// ссылки лягут при задаче в review, а финальный linking→done граф отклонит.
|
||||
func TestApply_ClaimFailureAbortsBeforeLinks(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
f.st.failSetState = func(st store.State) error {
|
||||
if st == store.StateLinking {
|
||||
return errors.New("boom: claim persist failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err := f.w.Apply(context.Background(), "1")
|
||||
if err == nil {
|
||||
t.Fatal("Apply must fail when linking claim persist fails")
|
||||
}
|
||||
if f.st.downloads["1"].State != store.StateReview {
|
||||
t.Errorf("state = %q, want review (claim not committed)", f.st.downloads["1"].State)
|
||||
}
|
||||
if len(f.st.links) != 0 {
|
||||
t.Errorf("file_links = %d, want 0 (no linking before committed claim)", len(f.st.links))
|
||||
}
|
||||
// Хардлинки на диск НЕ созданы — раскладка не запускалась.
|
||||
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
|
||||
if _, statErr := os.Stat(dst); statErr == nil {
|
||||
t.Errorf("hardlink %q created despite failed claim", dst)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApply_PersistFailureLeavesReview (MAJOR-4 A): хардлинки созданы, но
|
||||
// CreateFileLinks упал транзиентно → задача не должна застрять в linking; уходит
|
||||
// в review с причиной, повтор идемпотентен.
|
||||
func TestApply_PersistFailureLeavesReview(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
f.st.failCreateLinks = errors.New("boom: sqlite busy")
|
||||
|
||||
err := f.w.Apply(context.Background(), "1")
|
||||
if err == nil {
|
||||
t.Fatal("Apply must fail when persisting links fails")
|
||||
}
|
||||
if f.st.downloads["1"].State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review (not stranded in linking)", f.st.downloads["1"].State)
|
||||
}
|
||||
if f.st.downloads["1"].ErrorCode.String != "persist" {
|
||||
t.Errorf("error_code = %q, want persist", f.st.downloads["1"].ErrorCode.String)
|
||||
}
|
||||
// Хардлинки уже на диске (учёт лишь не записан) — повторный Apply их допишет.
|
||||
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
|
||||
if _, statErr := os.Stat(dst); statErr != nil {
|
||||
t.Errorf("expected hardlink on disk despite persist failure: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepLinking_OrphanedToReview (MAJOR-4 B): задача, застрявшая в linking
|
||||
// после краха, на тике/старте возвращается в review с причиной.
|
||||
func TestSweepLinking_OrphanedToReview(t *testing.T) {
|
||||
st := newMemStore()
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateLinking
|
||||
st.put(d)
|
||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
||||
|
||||
w.sweepLinking(context.Background())
|
||||
|
||||
got := st.downloads["1"]
|
||||
if got.State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review", got.State)
|
||||
}
|
||||
if got.ErrorCode.String != "interrupted" {
|
||||
t.Errorf("error_code = %q, want interrupted", got.ErrorCode.String)
|
||||
}
|
||||
if got.ErrorMsg.String == "" {
|
||||
t.Error("expected error_msg with reason")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepLinking_LeavesOtherStates: sweep трогает только linking, прочие
|
||||
// состояния (в т.ч. done) не задевает.
|
||||
func TestSweepLinking_LeavesOtherStates(t *testing.T) {
|
||||
st := newMemStore()
|
||||
done := completedDownload("1")
|
||||
done.State = store.StateDone
|
||||
st.put(done)
|
||||
review := completedDownload("2")
|
||||
review.State = store.StateReview
|
||||
st.put(review)
|
||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
||||
|
||||
w.sweepLinking(context.Background())
|
||||
|
||||
if st.downloads["1"].State != store.StateDone {
|
||||
t.Errorf("done task moved to %q", st.downloads["1"].State)
|
||||
}
|
||||
if st.downloads["2"].State != store.StateReview {
|
||||
t.Errorf("review task moved to %q", st.downloads["2"].State)
|
||||
}
|
||||
}
|
||||
|
||||
+218
-38
@@ -49,8 +49,15 @@ type Store interface {
|
||||
// PromoteCatched атомарно переводит catched → downloading с записью имени
|
||||
// (гард state='catched' — ре-валидация после сетевых вызовов вне блокировки).
|
||||
PromoteCatched(ctx context.Context, id, displayName string) error
|
||||
// SetDisplayName обновляет отображаемое имя постфактум (перелив канонического
|
||||
// имени после распознавания) — без гарда состояния, FSM не двигает.
|
||||
SetDisplayName(ctx context.Context, id, name string) error
|
||||
SetSourceMissCount(ctx context.Context, id string, n int) error
|
||||
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
|
||||
// SetRetriedAt проставляет время ручного retry — сброс базиса отсчёта
|
||||
// таймаутов (magnet_timeout/stuck_after), чтобы возврат в downloading не
|
||||
// ронял задачу снова на ближайшем тике.
|
||||
SetRetriedAt(ctx context.Context, id string, t time.Time) error
|
||||
|
||||
// Идентичность/инвариант «одна активная загрузка на infohash».
|
||||
ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error)
|
||||
@@ -70,6 +77,9 @@ type Store interface {
|
||||
ListOverrides(ctx context.Context, downloadID string) (map[string]string, error)
|
||||
CreateFileLinks(ctx context.Context, links []store.FileLink) error
|
||||
SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error
|
||||
// LiveTitleFolders — dst_path живых ссылок загрузок того же (provider,
|
||||
// provider_id), кроме excludeDownloadID (правило сходимости папки).
|
||||
LiveTitleFolders(ctx context.Context, provider, providerID, excludeDownloadID string) ([]string, error)
|
||||
LatestBatchID(ctx context.Context, downloadID string) (string, error)
|
||||
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
|
||||
DeleteFileLinksByBatch(ctx context.Context, batchID string) error
|
||||
@@ -86,6 +96,9 @@ type QBittorrent interface {
|
||||
Torrents(ctx context.Context, category string) ([]qbt.Torrent, error)
|
||||
Add(ctx context.Context, ar qbt.AddRequest) error
|
||||
Files(ctx context.Context, hash string) ([]qbt.File, error)
|
||||
Delete(ctx context.Context, hashes []string, deleteFiles bool) error
|
||||
// RenameTorrent задаёт имя уже добавленной раздачи по её ключу (Torrent.Hash).
|
||||
RenameTorrent(ctx context.Context, hash, name string) error
|
||||
}
|
||||
|
||||
// Recognizer — распознаватель (recognize.Recognizer).
|
||||
@@ -104,6 +117,10 @@ type Layouter interface {
|
||||
BuildLinks(p layout.Plan) ([]layout.Link, error)
|
||||
Apply(ctx context.Context, links []layout.Link) ([]layout.Result, error)
|
||||
Undo(ctx context.Context, links []layout.Link) (int, error)
|
||||
Remove(ctx context.Context, links []layout.Link) (int, error)
|
||||
// TitleFolder разбирает dst_path в папку тайтла и базу имени (правило
|
||||
// сходимости папки). ok=false, если путь не под корнем нужной библиотеки.
|
||||
TitleFolder(t layout.MediaType, dst string) (dir, base string, ok bool)
|
||||
}
|
||||
|
||||
// NotifyEvent — повод позвать пользователя.
|
||||
@@ -129,6 +146,12 @@ const (
|
||||
// catch_timeout (устойчивая недоступность qBit). Раздачи в qBittorrent нет,
|
||||
// восстановлению сверкой не подлежит.
|
||||
errCodeQbitAdd = "qbit_add"
|
||||
// errCodeSourceGone — раздача активной (downloading) загрузки устойчиво (после
|
||||
// дебаунса source_missing_threshold) пропала из qBittorrent: пользователь/другой
|
||||
// клиент её удалил. Distinct-код, отличный от qbit_error (реальная ошибка qBit) и
|
||||
// magnet_timeout/stalled (наша нетерпеливость). Восстановлению сверкой НЕ подлежит
|
||||
// (удаление намеренно) — но задача штатно retriable: Retry заново отдаёт источник.
|
||||
errCodeSourceGone = "source_gone"
|
||||
)
|
||||
|
||||
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
|
||||
@@ -318,12 +341,39 @@ func (w *Worker) pollOnce(ctx context.Context) {
|
||||
// Быстрый приём отложил добавление в qBittorrent: подхватываем пойманные
|
||||
// (catched) загрузки и добавляем их (сеть — вне блокировки переходов).
|
||||
w.processCatched(ctx)
|
||||
// Восстанавливаем задачи, застрявшие в linking после краха между claim и
|
||||
// финальным переходом (иначе их не листит никто — вечный лимбо).
|
||||
w.sweepLinking(ctx)
|
||||
// Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке).
|
||||
if w.recognizer != nil {
|
||||
w.recognizePending(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// sweepLinking восстанавливает задачи, застрявшие в состоянии linking. Любая
|
||||
// linking-задача, видимая под w.mu, устарела по построению: активная раскладка
|
||||
// (linkPlan) держит w.mu на всё время и завершает переход из linking ДО отпускания
|
||||
// замка — значит эта задача осталась в linking после краха процесса между claim
|
||||
// (переходом в linking) и финальным переходом. Возвращаем её в review с причиной;
|
||||
// человек повторит Apply (linkPlan идемпотентен), и незаписанный учёт хардлинков
|
||||
// допишется. Так у linking появляется владелец на рестарте/тике — инвариант «у
|
||||
// каждого нетерминального состояния есть владелец» (как recognizePending для
|
||||
// recognizing). Выполняется на каждом тике и на старте (первый pollOnce до цикла).
|
||||
func (w *Worker) sweepLinking(ctx context.Context) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
stuck, err := w.store.ListDownloadsByState(ctx, store.StateLinking)
|
||||
if err != nil {
|
||||
w.log.Warn("sweep linking list failed", "capability", capFileLayout, "error", err)
|
||||
return
|
||||
}
|
||||
for _, d := range stuck {
|
||||
lctx := w.scoped(ctx, capFileLayout, d.ID, d.PrimaryInfohash())
|
||||
w.transition(lctx, d, store.StateReview, "interrupted",
|
||||
"прерванная раскладка, повтори применение")
|
||||
}
|
||||
}
|
||||
|
||||
// processCatched — асинхронный шаг добавления пойманных загрузок в qBittorrent.
|
||||
// Для каждой catched: (предохранитель) если висит дольше catch_timeout — уводим
|
||||
// в failed; иначе выводим имя и добавляем в qBit. Медленные вызовы (LLM-namer,
|
||||
@@ -470,9 +520,22 @@ func (w *Worker) Poll(ctx context.Context) error {
|
||||
continue // нечем сопоставить (в Ф1 не случается: magnet всегда с infohash)
|
||||
}
|
||||
t, ok := torrentFor(d, byHash)
|
||||
// Дебаунс пропажи источника у активной загрузки (тот же счётчик, что и
|
||||
// сверка рассинхрона): промах наращивает source_miss_count, появление
|
||||
// раздачи сбрасывает его. Устойчивая пропажа (после порога) уводит задачу
|
||||
// в failed(source_gone) — иначе downloading без раздачи в qBittorrent
|
||||
// оставался бы вечным зомби (MAJOR-3).
|
||||
if present := w.debounceSource(ctx, d, ok); !present {
|
||||
lctx := w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
||||
logctx.From(lctx).Warn("active download source gone from qbittorrent",
|
||||
"miss_count", d.SourceMissCount+1)
|
||||
w.transition(lctx, d, store.StateFailed, errCodeSourceGone,
|
||||
"источник удалён из qBittorrent")
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
w.log.Warn("active download not found in qbittorrent",
|
||||
"capability", capIngest, "download_id", d.ID, "infohash", d.PrimaryInfohash())
|
||||
// До порога: транзиентный промах (например рестарт демона qBit) — ждём
|
||||
// следующий тик, задачу не трогаем.
|
||||
continue
|
||||
}
|
||||
w.captureInfohashes(ctx, d, t)
|
||||
@@ -506,21 +569,29 @@ func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent)
|
||||
}
|
||||
}
|
||||
|
||||
// checkTimeouts помечает зависшие задачи. Возраст считаем от факта в
|
||||
// qBittorrent (added_on), а не от created_at: базис переживает retry и
|
||||
// усыновление раздачи (см. design download-failure-recovery). magnet_timeout —
|
||||
// редкий страховочный предохранитель (дефолт 24h); настоящие провалы ловит
|
||||
// checkTimeouts помечает зависшие задачи двумя разными мерами:
|
||||
// - magnet_timeout — по ВОЗРАСТУ торрента (metaDL дольше magnet_timeout без
|
||||
// метаданных): страховочный предохранитель (дефолт 24h), базис — added_on;
|
||||
// - stuck_after — по ДЛИТЕЛЬНОСТИ ПРОСТОЯ (stalledDL без движения данных
|
||||
// дольше stuck_after): базис — last_activity, а не возраст. Иначе долго
|
||||
// качавшийся торрент, на миг зашедший в stalledDL, ложно уходит в stuck со
|
||||
// «stalled for 5h» (см. state-reconciliation, MAJOR-2).
|
||||
//
|
||||
// Оба базиса приподняты до retried_at (ручной retry), чтобы возврат в
|
||||
// downloading не ронял задачу снова на ближайшем тике. Настоящие провалы ловит
|
||||
// classErrored, а ожившие задачи воскрешает reconcileRecovery.
|
||||
func (w *Worker) checkTimeouts(ctx context.Context, d store.Download, t qbt.Torrent) {
|
||||
age := w.torrentAge(d, t)
|
||||
|
||||
switch {
|
||||
case isMeta(t.State) && w.cfg.MagnetTimeout > 0 && age > w.cfg.MagnetTimeout:
|
||||
w.transition(ctx, d, store.StateFailed, errCodeMagnetTimeout,
|
||||
fmt.Sprintf("no metadata after %s", age.Truncate(time.Second)))
|
||||
case isStalledDL(t.State) && w.cfg.StuckAfter > 0 && age > w.cfg.StuckAfter:
|
||||
w.transition(ctx, d, store.StateStuck, errCodeStalled,
|
||||
fmt.Sprintf("stalled for %s", age.Truncate(time.Second)))
|
||||
case isMeta(t.State) && w.cfg.MagnetTimeout > 0:
|
||||
if age, ok := w.torrentAge(d, t); ok && age > w.cfg.MagnetTimeout {
|
||||
w.transition(ctx, d, store.StateFailed, errCodeMagnetTimeout,
|
||||
fmt.Sprintf("no metadata after %s", age.Truncate(time.Second)))
|
||||
}
|
||||
case isStalledDL(t.State) && w.cfg.StuckAfter > 0:
|
||||
if idle, ok := w.stallDuration(d, t); ok && idle > w.cfg.StuckAfter {
|
||||
w.transition(ctx, d, store.StateStuck, errCodeStalled,
|
||||
fmt.Sprintf("stalled for %s", idle.Truncate(time.Second)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,33 +643,89 @@ func (w *Worker) captureInfohashes(ctx context.Context, d store.Download, t qbt.
|
||||
}
|
||||
}
|
||||
|
||||
// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис,
|
||||
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
|
||||
// отдал added_on.
|
||||
func (w *Worker) torrentAge(d store.Download, t qbt.Torrent) time.Duration {
|
||||
// torrentAge — возраст торрента для magnet_timeout: от added_on в qBittorrent
|
||||
// (надёжный базис, переживает усыновление), с фолбэком на created_at задачи,
|
||||
// если qBit не отдал added_on (NIT-10). Базис приподнят до retried_at, чтобы
|
||||
// ручной retry сбрасывал отсчёт. ok=false — базис неизвестен (ни added_on, ни
|
||||
// разбираемого created_at): таймаут не срабатывает, фиксируем диагностикой.
|
||||
func (w *Worker) torrentAge(d store.Download, t qbt.Torrent) (time.Duration, bool) {
|
||||
basis, ok := w.addedBasis(d, t)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return w.now().Sub(w.retriedFloor(d, basis)), true
|
||||
}
|
||||
|
||||
// stallDuration — длительность простоя торрента для stuck_after: от
|
||||
// last_activity qBittorrent (момент последнего движения данных), с фолбэком на
|
||||
// базис добавления, если qBit не отдал пригодного last_activity. Базис приподнят
|
||||
// до retried_at (ручной retry даёт свежее окно). ok=false — базис неизвестен.
|
||||
//
|
||||
// last_activity в будущем (перекос часов, sentinel «никогда не был активен»)
|
||||
// трактуем как непригодное значение и падаем на addedBasis: иначе простой вышел
|
||||
// бы отрицательным и реально застрявший торрент никогда бы не пометился stuck.
|
||||
func (w *Worker) stallDuration(d store.Download, t qbt.Torrent) (time.Duration, bool) {
|
||||
var basis time.Time
|
||||
if la := time.Unix(t.LastActivity, 0).UTC(); t.LastActivity > 0 && !la.After(w.now()) {
|
||||
basis = la
|
||||
} else {
|
||||
var ok bool
|
||||
if basis, ok = w.addedBasis(d, t); !ok {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return w.now().Sub(w.retriedFloor(d, basis)), true
|
||||
}
|
||||
|
||||
// addedBasis — момент добавления торрента: added_on qBittorrent, иначе
|
||||
// created_at задачи (NIT-10). ok=false — ни того, ни другого разобрать не
|
||||
// удалось; фиксируем диагностикой.
|
||||
func (w *Worker) addedBasis(d store.Download, t qbt.Torrent) (time.Time, bool) {
|
||||
if t.AddedOn > 0 {
|
||||
return w.now().Sub(time.Unix(t.AddedOn, 0).UTC())
|
||||
return time.Unix(t.AddedOn, 0).UTC(), true
|
||||
}
|
||||
created, err := d.CreatedTime()
|
||||
if err != nil {
|
||||
// Ни added_on от qBit, ни разбираемого created_at — возраст неизвестен,
|
||||
// таймауты не сработают; фиксируем диагностикой.
|
||||
w.log.Warn("cannot determine torrent age",
|
||||
"capability", capIngest, "download_id", d.ID,
|
||||
"created_at", d.CreatedAt, "error", err)
|
||||
return 0
|
||||
return time.Time{}, false
|
||||
}
|
||||
return w.now().Sub(created)
|
||||
return created, true
|
||||
}
|
||||
|
||||
// transition пишет новое состояние и логирует переход.
|
||||
// retriedFloor приподнимает базис отсчёта таймаута до времени последнего ручного
|
||||
// retry: после retry задача получает свежее окно и не падает повторно на
|
||||
// ближайшем тике (см. state-reconciliation «Ручной повтор», MAJOR-1).
|
||||
func (w *Worker) retriedFloor(d store.Download, basis time.Time) time.Time {
|
||||
if r, ok := d.RetriedTime(); ok && r.After(basis) {
|
||||
return r
|
||||
}
|
||||
return basis
|
||||
}
|
||||
|
||||
// transition пишет новое состояние и логирует переход. Fire-and-forget обёртка
|
||||
// над transitionErr: применяется там, где переход терминален для шага — за ним
|
||||
// нет побочного эффекта, зависящего от факта записи claim (reconcile, таймауты,
|
||||
// команды ревью, финальные переходы linkPlan, sweep). Ошибку записи гасит (её
|
||||
// уже залогировал transitionErr).
|
||||
func (w *Worker) transition(ctx context.Context, d store.Download, state store.State, code, msg string) {
|
||||
_ = w.transitionErr(ctx, d, state, code, msg)
|
||||
}
|
||||
|
||||
// transitionErr пишет новое состояние, шлёт пинги/скан, логирует переход и
|
||||
// ВОЗВРАЩАЕТ ошибку записи. На claim-then-side-effect путях (ручное Apply,
|
||||
// авто-раскладка в finishRecognition) провал claim перехода в `linking` ОБЯЗАН
|
||||
// прервать выполнение ДО побочных эффектов (хардлинков): иначе ссылки лягут при
|
||||
// незакоммиченном claim, а финальный переход из фактического (не `linking`)
|
||||
// состояния граф отклонит — задача застрянет со stale-планом (MINOR-7).
|
||||
func (w *Worker) transitionErr(ctx context.Context, d store.Download, state store.State, code, msg string) error {
|
||||
// FromOr, а не From: если вызывающий не завёл scoped-логгер, падаем на
|
||||
// w.log (настроенный), а не на slog.Default().
|
||||
log := logctx.FromOr(ctx, w.log)
|
||||
if err := w.store.SetDownloadState(ctx, d.ID, state, code, msg); err != nil {
|
||||
log.Error("state transition failed", "from", d.State, "to", state, "error", err)
|
||||
return
|
||||
return fmt.Errorf("transition %s → %s: %w", d.State, state, err)
|
||||
}
|
||||
log.Info("state transition", "from", d.State, "to", state, "code", code)
|
||||
|
||||
@@ -631,6 +758,7 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
|
||||
gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.PrimaryInfohash())
|
||||
go func() { _ = w.scanner.RefreshLibraries(gctx) }()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldNotifyFail дебаунсит повторные уведомления о падении одной задачи
|
||||
@@ -652,9 +780,34 @@ func (w *Worker) shouldNotifyFail(id string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// logCmd — единый чокпоинт логирования исхода команды воркера
|
||||
// (apply/cancel/retry/…), вызываемой транспортами. Конвенция (logging.md,
|
||||
// раздел «Ошибки»): доменную ошибку логирует граница домена ровно один раз, а
|
||||
// транспорты (HTTP/web/Telegram) — нет. Команды воркера и есть эта граница.
|
||||
//
|
||||
// Уровень — по адресату: штатный отказ по состоянию/наличию
|
||||
// (ErrConflict/ErrNotReady/ErrNotFound) адресован пользователю, он уже получил
|
||||
// ответ на поверхности — DEBUG; всё прочее (сбой БД/ФС/зависимости) адресовано
|
||||
// команде — ERROR. Успех (err == nil) — молча. Ставится в defer при именованном
|
||||
// возврате, поэтому видит финальную ошибку и scoped-логгер, накопленный в ctx.
|
||||
func (w *Worker) logCmd(ctx context.Context, cmd, id string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
log := logctx.FromOr(ctx, w.log)
|
||||
switch {
|
||||
case errors.Is(err, ErrConflict), errors.Is(err, ErrNotReady), errors.Is(err, ErrInvalidInput),
|
||||
errors.Is(err, store.ErrNotFound), errors.Is(err, layout.ErrCollision):
|
||||
log.Debug("command rejected", "command", cmd, "download_id", id, "error", err)
|
||||
default:
|
||||
log.Error("command failed", "command", cmd, "download_id", id, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
|
||||
// раздачу (источник неприкосновенен).
|
||||
func (w *Worker) Cancel(ctx context.Context, id string) error {
|
||||
func (w *Worker) Cancel(ctx context.Context, id string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "cancel", id, err) }()
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -663,18 +816,20 @@ func (w *Worker) Cancel(ctx context.Context, id string) error {
|
||||
return fmt.Errorf("cancel: %w", err)
|
||||
}
|
||||
if d.State.IsTerminal() {
|
||||
return fmt.Errorf("cancel: download %s is already terminal (%s)", id, d.State)
|
||||
return fmt.Errorf("cancel: download %s is already terminal (%s): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
if err := w.store.SetDownloadState(ctx, id, store.StateCancelled, "", ""); err != nil {
|
||||
return fmt.Errorf("cancel: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download cancelled", "from", d.State)
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("state transition",
|
||||
"from", d.State, "to", store.StateCancelled)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в
|
||||
// qBittorrent и возвращает в downloading.
|
||||
func (w *Worker) Retry(ctx context.Context, id string) error {
|
||||
func (w *Worker) Retry(ctx context.Context, id string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "retry", id, err) }()
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -683,18 +838,24 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
if d.State != store.StateFailed && d.State != store.StateStuck {
|
||||
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable", id, d.State)
|
||||
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable: %w", id, d.State, ErrConflict)
|
||||
}
|
||||
// Если раздача уже жива в qBittorrent — перецепляемся к ней, повторный Add
|
||||
// не нужен (и вреден: вслепую дублировал бы торрент). Add — только когда
|
||||
// источника в qBittorrent нет. Базис таймаута берётся от added_on, поэтому
|
||||
// возврат в downloading не роняет задачу снова на ближайшем тике.
|
||||
alive := false
|
||||
// Если раздача уже жива и ЗДОРОВА в qBittorrent — перецепляемся к ней,
|
||||
// повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add —
|
||||
// когда источника в qBittorrent нет ИЛИ он в состоянии ошибки: перецепка к
|
||||
// сломанному торренту (error/missingFiles) бессмысленна — reconcile тут же
|
||||
// вернул бы задачу в failed, поэтому пробуем повторно отдать источник
|
||||
// (NIT-12). Базис таймаута сбрасывается ниже через retried_at, поэтому
|
||||
// возврат в downloading не роняет задачу снова на ближайшем тике (MAJOR-1).
|
||||
reAdd := true
|
||||
if hashes := d.HashList(); len(hashes) > 0 {
|
||||
_, alive, err = w.torrentByInfohash(ctx, hashes)
|
||||
var t qbt.Torrent
|
||||
var alive bool
|
||||
t, alive, err = w.torrentByInfohash(ctx, hashes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
reAdd = !alive || classify(t.State) == classErrored
|
||||
}
|
||||
// Гард инварианта — ДО побочного эффекта в qBittorrent: пока задача лежала
|
||||
// в failed, тем же infohash могла завладеть другая активная задача — тогда
|
||||
@@ -705,7 +866,7 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
|
||||
}
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
if !alive {
|
||||
if reAdd {
|
||||
// Добавляем заново по типу источника (magnet — ссылкой, torrent —
|
||||
// сохранёнными байтами файлом). Rename при retry не выводим (namer здесь
|
||||
// не зовём — имя уже могло быть выведено при первом добавлении).
|
||||
@@ -729,7 +890,26 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
|
||||
return fmt.Errorf("retry: add to qbittorrent: %w", err)
|
||||
}
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download retried", "from", d.State)
|
||||
// Сброс базиса отсчёта таймаутов (MAJOR-1): без него живой, но давно
|
||||
// добавленный/простаивающий торрент снова упал бы по magnet_timeout/
|
||||
// stuck_after на ближайшем тике. Best-effort: сбой лишь лишает свежего окна
|
||||
// (WARN), сам retry уже состоялся.
|
||||
if err := w.store.SetRetriedAt(ctx, id, w.now()); err != nil {
|
||||
w.log.Warn("retry basis reset failed",
|
||||
"capability", capReview, "download_id", id, "error", err)
|
||||
}
|
||||
// Сброс счётчика пропусков источника: retried source_gone-задача иначе вошла бы
|
||||
// в downloading с source_miss_count == threshold и упала бы снова на ближайшем
|
||||
// тике, если переотданная раздача ещё не видна в выдаче qBittorrent — без
|
||||
// обещанного грейс-окна (MAJOR-3). Best-effort: сбой лишь лишает свежего окна.
|
||||
if d.SourceMissCount != 0 {
|
||||
if err := w.store.SetSourceMissCount(ctx, id, 0); err != nil {
|
||||
w.log.Warn("retry miss count reset failed",
|
||||
"capability", capReview, "download_id", id, "error", err)
|
||||
}
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("state transition",
|
||||
"from", d.State, "to", store.StateDownloading)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +191,15 @@ func (f *fakeStore) PromoteCatched(_ context.Context, id, displayName string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetDisplayName(_ context.Context, id, name string) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
d.DisplayName = name
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
@@ -211,6 +220,15 @@ func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetRetriedAt(_ context.Context, id string, t time.Time) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
d.RetriedAt = store.NullString(store.FormatTime(t))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
|
||||
|
||||
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (string, error) {
|
||||
@@ -226,6 +244,9 @@ func (f *fakeStore) ListOverrides(_ context.Context, _ string) (map[string]strin
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil }
|
||||
func (f *fakeStore) LiveTitleFolders(_ context.Context, _, _, _ string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ string, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -246,10 +267,20 @@ func (f *fakeStore) GetCandidate(_ context.Context, _ string) (*store.MetadataCa
|
||||
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
type fakeQbt struct {
|
||||
torrents []qbt.Torrent
|
||||
added []qbt.AddRequest
|
||||
addErr error
|
||||
files []qbt.File
|
||||
torrents []qbt.Torrent
|
||||
torrentsErr error
|
||||
added []qbt.AddRequest
|
||||
addErr error
|
||||
files []qbt.File
|
||||
deleted [][]string // хеши каждого вызова Delete
|
||||
deleteErr error
|
||||
renamed []renameCall // каждый вызов RenameTorrent (hash, name)
|
||||
renameErr error
|
||||
}
|
||||
|
||||
type renameCall struct {
|
||||
hash string
|
||||
name string
|
||||
}
|
||||
|
||||
// Torrents имитирует /torrents/info: пустая категория — все торренты, иначе
|
||||
@@ -257,6 +288,9 @@ type fakeQbt struct {
|
||||
// регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна
|
||||
// теряться при поиске по infohash.
|
||||
func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) {
|
||||
if f.torrentsErr != nil {
|
||||
return nil, f.torrentsErr
|
||||
}
|
||||
if category == "" {
|
||||
return f.torrents, nil
|
||||
}
|
||||
@@ -281,6 +315,22 @@ func (f *fakeQbt) Files(_ context.Context, _ string) ([]qbt.File, error) {
|
||||
return f.files, nil
|
||||
}
|
||||
|
||||
func (f *fakeQbt) Delete(_ context.Context, hashes []string, _ bool) error {
|
||||
if f.deleteErr != nil {
|
||||
return f.deleteErr
|
||||
}
|
||||
f.deleted = append(f.deleted, hashes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeQbt) RenameTorrent(_ context.Context, hash, name string) error {
|
||||
if f.renameErr != nil {
|
||||
return f.renameErr
|
||||
}
|
||||
f.renamed = append(f.renamed, renameCall{hash, name})
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
|
||||
w := New(st, qb, nil, nil, Config{
|
||||
Category: "jellybit",
|
||||
@@ -361,15 +411,19 @@ func TestPollMatchesByInfohashV2(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollIgnoresMissingTorrent(t *testing.T) {
|
||||
// Единичный транзиентный промах (торрента нет в этом тике) при пороге дебаунса
|
||||
// > 1 не двигает состояние — устойчивую пропажу и переход в source_gone проверяют
|
||||
// TestSourceGone* в recovery_test.
|
||||
func TestPollToleratesTransientMissingTorrent(t *testing.T) {
|
||||
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||
qb := &fakeQbt{torrents: nil} // торрента в qBittorrent нет
|
||||
w := newTestWorker(st, qb)
|
||||
w.cfg.SourceMissingThreshold = 3 // порог не достигнут за один тик
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.downloads["1"].State != store.StateDownloading {
|
||||
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads["1"].State)
|
||||
t.Errorf("единичный промах не должен менять состояние, got %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-08
|
||||
@@ -0,0 +1,135 @@
|
||||
# Design — Пропажа источника у активной загрузки
|
||||
|
||||
## Контекст
|
||||
|
||||
`Poll` под `w.mu` листает `downloading`-задачи и для каждой ищет торрент в
|
||||
`byHash`. Промах сейчас — только `Warn` + `continue` (worker.go:503-508). Механизм
|
||||
дебаунса пропажи источника уже есть в `state-reconciliation`: поле
|
||||
`download.source_miss_count`, метод `debounceSource(ctx, d, sourceSeen) bool` и
|
||||
порог `[worker].source_missing_threshold` (дефолт 3). Он применяется только в
|
||||
`reconcileOneDesync`. Задача — переиспользовать его в активном цикле.
|
||||
|
||||
## Решение
|
||||
|
||||
### Целевое состояние: `failed`/`source_gone`, а не `deleted`
|
||||
|
||||
Развилка из беклога: «downloading → `deleted` ИЛИ `failed` с distinct error_code
|
||||
для re-Add». Выбран **`failed`/`source_gone`**:
|
||||
|
||||
- **Восстановимость.** Источник у нас сохранён (magnet `source_ref` или байты
|
||||
`.torrent`). `Retry` заново отдаёт его в qBittorrent — задача продолжится.
|
||||
`deleted` терминален навсегда и не оставляет пользователю выхода, хотя пропажа
|
||||
могла быть случайной.
|
||||
- **Минимальная дельта графа.** Ребро `downloading → failed` уже объявлено
|
||||
(`allowedTransitions`), нового ребра не нужно. `downloading → deleted` ребра нет
|
||||
— пришлось бы вводить.
|
||||
- **Переиспользование инфраструктуры.** `StateFailed` уже шлёт `EventFailed`
|
||||
(с дебаунсом уведомлений) и уже retriable — не нужен ни новый notify-повод, ни
|
||||
новая команда.
|
||||
- **Distinct `error_code`.** `source_gone` отличается от `qbit_error`
|
||||
(реальная ошибка qBit), `magnet_timeout`/`stalled` (наша нетерпеливость) — по
|
||||
нему UI/лог различает причину, и он **осознанно не в наборе** `reconcileRecovery`
|
||||
(`ListRecoverable(magnet_timeout, stalled)`): намеренно удалённый источник не
|
||||
должен молча воскресать, у пользователя есть явный `Retry`.
|
||||
|
||||
`deriveState(false, false) = deleted` (матрица «источник × цель») здесь НЕ
|
||||
применяется: у активной `downloading`-задачи цели ещё нет, и это не путь сверки
|
||||
рассинхрона, а отдельное правило прямого пути. Матрица по-прежнему не трогает
|
||||
активные состояния.
|
||||
|
||||
### Переиспользование дебаунса в активном цикле
|
||||
|
||||
`debounceSource(ctx, d, sourceSeen)`:
|
||||
- `sourceSeen=true` → сбрасывает счётчик в 0, возвращает `true`;
|
||||
- `sourceSeen=false` → инкремент, возвращает `miss < threshold`.
|
||||
|
||||
Активный цикл `Poll` перестраивается так:
|
||||
|
||||
```go
|
||||
t, ok := torrentFor(d, byHash)
|
||||
if present := w.debounceSource(ctx, d, ok); !present {
|
||||
// порог промахов исчерпан — источник действительно пропал
|
||||
lctx := w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
||||
w.transition(lctx, d, store.StateFailed, errCodeSourceGone,
|
||||
"источник удалён из qBittorrent")
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
// до порога: транзиентный промах (рестарт qBit) — ждём следующий тик
|
||||
continue
|
||||
}
|
||||
w.captureInfohashes(ctx, d, t)
|
||||
w.captureSourceAddedAt(ctx, d, t)
|
||||
w.reconcile(ctx, d, t)
|
||||
```
|
||||
|
||||
`debounceSource` вызывается в ЛЮБОМ случае (и при `ok`, и при промахе), поэтому
|
||||
появление раздачи сбрасывает счётчик, накопленный ранее.
|
||||
|
||||
### Сброс `source_miss_count` при `Retry` (иначе нет грейс-окна)
|
||||
|
||||
`source_gone` — единственный путь, оставляющий у **retriable** задачи ненулевой
|
||||
`source_miss_count` (у `magnet_timeout`/`stalled` источник на падающем тике
|
||||
присутствовал, значит счётчик уже 0). Без сброса `Retry` вернул бы задачу в
|
||||
`downloading` с `source_miss_count == threshold`: на первом же тике, если
|
||||
переотданная раздача ещё не видна в выдаче qBittorrent (Add без ошибки, но
|
||||
регистрация с задержкой), `debounceSource` даёт `miss = threshold+1` → мгновенный
|
||||
повторный `source_gone`, минуя обещанное спекой грейс-окно.
|
||||
|
||||
Поэтому `Retry` SHALL сбрасывать `source_miss_count` в 0 — рядом с существующим
|
||||
сбросом `retried_at` (та же интенция «свежее окно», MAJOR-1), best-effort. Это
|
||||
покрывает общий случай: любая retriable-задача входит в `downloading` с чистым
|
||||
счётчиком. Путь авто-восстановления (`reconcileRecovery`) сброса не требует —
|
||||
туда `source_gone` не попадает, а у `magnet_timeout`/`stalled` счётчик уже 0.
|
||||
|
||||
### Почему нет двойного учёта `source_miss_count`
|
||||
|
||||
Задача в один момент времени находится ровно в одном состоянии: либо в активном
|
||||
цикле (`downloading`), либо в `reconcileDesync` (`done`/`target_missing`/
|
||||
`orphaned`) — не в обоих за тик. Поле `source_miss_count` используется с единой
|
||||
семантикой «сброс при наличии источника», поэтому пересечения нет. При переходе
|
||||
`downloading → completed` счётчик уже 0 (источник виден на том же тике).
|
||||
|
||||
### catched-исключение сохраняется
|
||||
|
||||
`catched`-задачи в активный цикл не попадают (там нет раздачи по дизайну) —
|
||||
требование «catched не считается пропажей раздачи» не затрагивается: цикл листает
|
||||
только `StateDownloading`.
|
||||
|
||||
## Альтернативы
|
||||
|
||||
- **`downloading → deleted`** — отклонено: терминально без выхода, требует нового
|
||||
ребра, теряет ещё-скачиваемую задачу при, возможно, случайной пропаже.
|
||||
- **Авто-восстановление `source_gone` при возврате раздачи** (добавить в
|
||||
`reconcileRecovery`) — отклонено для v1: намеренное удаление не должно тихо
|
||||
оживать; есть явный `Retry`. Отложено (можно добавить позже отдельным change,
|
||||
если появится боль).
|
||||
- **Отдельный notify-повод `EventSourceGone`** — избыточно: `EventFailed`
|
||||
семантически покрывает «задача упала», текст уведомления берётся по состоянию.
|
||||
|
||||
## Принятые ограничения (вне scope)
|
||||
|
||||
- **Дебаунс уведомлений может проглотить пинг.** `shouldNotifyFail` дебаунсит
|
||||
`EventFailed` по `download_id` на 1 ч. Узкая последовательность (задача уже
|
||||
падала `stalled`/`stuck` с пингом < 1 ч назад → `Retry` → источник удалён →
|
||||
`source_gone` в то же окно) не пришлёт повторный пинг. Приемлемо: смена
|
||||
состояния и телеметрия всё равно фиксируются (главная боль зомби — «никто не
|
||||
двигает» — закрыта); отдельный пинг именно про source_gone не критичен.
|
||||
- **Задача `downloading` с пустым `Infohashes`** остаётся вне правила: гард
|
||||
`len(d.Infohashes)==0 { continue }` стоит до `torrentFor` (как и в
|
||||
`reconcileOneDesync`). В Ф1 не случается (magnet всегда с infohash); отдельный
|
||||
класс зомби, этим change не адресуется.
|
||||
- **Пустая выдача qBittorrent при живом демоне** (HTTP 200 сразу после рестарта,
|
||||
resume-data ещё не загружены) нарастит промахи всем активным задачам. Экспозиция
|
||||
предсуществующая и общая с `reconcileDesync` (тот массово пометил бы
|
||||
`orphaned`/`deleted`); change лишь распространяет её на активные загрузки.
|
||||
Дебаунс (`source_missing_threshold`) — уже имеющаяся защита; принимаем.
|
||||
|
||||
## Тесты
|
||||
|
||||
- Промах меньше порога → задача остаётся `downloading` (дебаунс).
|
||||
- Промах ≥ порога → `downloading → failed`/`source_gone` + `EventFailed`.
|
||||
- Возврат раздачи до порога → счётчик сброшен, задача жива, ушла по обычному
|
||||
reconcile.
|
||||
- `source_gone` не воскрешается `reconcileRecovery` (источник вернулся — задача
|
||||
остаётся `failed` до ручного `Retry`).
|
||||
@@ -0,0 +1,47 @@
|
||||
# Пропажа источника у активной загрузки (MAJOR-3)
|
||||
|
||||
## Why
|
||||
|
||||
Если раздача исчезает из qBittorrent (пользователь или другой клиент удалил её),
|
||||
пока задача в `downloading`, задача становится **вечным зомби**. Поллинг активных
|
||||
загрузок промахивается по `torrentFor`, пишет `Warn "active download not found in
|
||||
qbittorrent"` и делает `continue` — и так каждый тик, бесконечно. Состояние не
|
||||
меняется, уведомления нет, телеметрии нет, `checkTimeouts` требует торрент (значит
|
||||
таймауты-предохранители не срабатывают). Единственный выход — ручной Cancel/Defer.
|
||||
|
||||
Дыра: сверка рассинхрона (`state-reconciliation`) покрывает пропажу источника
|
||||
только для уже разложенных состояний (`done`/`target_missing`/`orphaned`), а
|
||||
восстановление (`reconcileRecovery`) — только `failed`/`stuck`. Для активного
|
||||
`downloading` пропажей источника не владеет НИКТО. Сравни: та же пропажа в
|
||||
`completed`/`recognizing`/`review` деградирует штатно (там источник уже не нужен
|
||||
или его отсутствие ведёт через матрицу).
|
||||
|
||||
## What Changes
|
||||
|
||||
- Поллинг активных загрузок SHALL применять **дебаунс пропажи источника** (тот же
|
||||
`SourceMissCount` / `[worker].source_missing_threshold`, что и сверка
|
||||
рассинхрона): промах `torrentFor` наращивает счётчик, любое появление раздачи
|
||||
его сбрасывает.
|
||||
- После порога подряд идущих промахов задача SHALL переходить `downloading →
|
||||
failed` с новым отдельным `error_code` `source_gone` и уведомлять автора
|
||||
(`EventFailed`).
|
||||
- `source_gone` **восстановлению сверкой не подлежит** (не входит в набор
|
||||
`reconcileRecovery`): удаление источника из qBittorrent — намеренное действие,
|
||||
молча воскрешать задачу нельзя. Задача остаётся штатно **retriable**: `Retry`
|
||||
заново отдаёт источник (у нас сохранены magnet/`.torrent`-байты).
|
||||
|
||||
Ребро графа `downloading → failed` уже объявлено — нового ребра не требуется.
|
||||
Меняется только набор `error_code` и поведение поллинга активных загрузок.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- `download-tracking` — ADDED: «Пропажа источника у активной загрузки».
|
||||
|
||||
## Impact
|
||||
|
||||
- Код: `internal/worker/worker.go` (поллинг активных загрузок, новый
|
||||
`errCodeSourceGone`), тесты воркера.
|
||||
- Спека: `download-tracking` (новое требование). Диаграмма `docs/specs/workflow.md`
|
||||
(миррор FSM) — добавить ребро `downloading → failed (source_gone)`.
|
||||
- Миграции БД нет: `source_miss_count` уже существует, новых полей не вводим.
|
||||
- Конфиг без изменений: переиспользуем `source_missing_threshold`.
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# download-tracking Specification
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Пропажа источника у активной загрузки
|
||||
|
||||
Поллинг активных загрузок (`downloading`) SHALL обнаруживать пропажу источника:
|
||||
если раздача, совпадающая с любым из известных хешей загрузки, отсутствует в
|
||||
выдаче qBittorrent, система SHALL применять **тот же дебаунс пропажи источника**,
|
||||
что и сверка рассинхрона (счётчик `source_miss_count`, порог
|
||||
`[worker].source_missing_threshold`; см. `state-reconciliation` «Дебаунс пропажи
|
||||
источника»). Любое обнаружение раздачи SHALL сбрасывать счётчик.
|
||||
|
||||
После `N` подряд идущих тиков без раздачи (`N =
|
||||
[worker].source_missing_threshold`) система SHALL переводить загрузку
|
||||
`downloading → failed` с `error_code` `source_gone` и уведомлять автора. До
|
||||
достижения порога загрузка SHALL оставаться в `downloading` (транзиентная
|
||||
недоступность qBittorrent, например рестарт демона, не должна ронять задачу).
|
||||
|
||||
`source_gone` система SHALL трактовать как отдельную причину, отличную от
|
||||
`qbit_error` (реальная ошибка qBittorrent) и от `magnet_timeout`/`stalled` (наша
|
||||
нетерпеливость). Восстановлению сверкой (`reconcileRecovery`) `source_gone`
|
||||
подлежать SHALL NOT — удаление источника из qBittorrent намеренно, молча
|
||||
воскрешать задачу нельзя. Задача SHALL оставаться штатно восстановимой вручную
|
||||
(`Retry` заново отдаёт сохранённый источник в qBittorrent).
|
||||
|
||||
Состояние `catched` этим правилом затрагиваться SHALL NOT: у пойманной загрузки
|
||||
раздачи в qBittorrent ещё нет по дизайну (см. «catched не считается пропажей
|
||||
раздачи»), а цикл активных загрузок листает только `downloading`.
|
||||
|
||||
#### Scenario: Источник пропал у активной загрузки дольше порога
|
||||
|
||||
- **GIVEN** загрузка в `downloading`, чья раздача удалена из qBittorrent
|
||||
- **WHEN** раздача отсутствует `source_missing_threshold` подряд идущих тиков
|
||||
- **THEN** загрузка переходит в `failed` с `error_code` `source_gone`
|
||||
- **AND** автор загрузки уведомляется
|
||||
|
||||
#### Scenario: Кратковременная пропажа источника не роняет задачу
|
||||
|
||||
- **GIVEN** загрузка в `downloading`
|
||||
- **WHEN** раздача отсутствует в qBittorrent меньше `source_missing_threshold`
|
||||
тиков подряд
|
||||
- **THEN** загрузка остаётся в `downloading`
|
||||
|
||||
#### Scenario: Возврат раздачи сбрасывает счётчик
|
||||
|
||||
- **GIVEN** загрузка в `downloading` с накопленными промахами источника (меньше
|
||||
порога)
|
||||
- **WHEN** раздача снова обнаружена в qBittorrent
|
||||
- **THEN** счётчик промахов сбрасывается в ноль и загрузка ведётся обычной
|
||||
сверкой состояния
|
||||
|
||||
#### Scenario: source_gone не воскрешается сверкой
|
||||
|
||||
- **GIVEN** загрузка в `failed` с `error_code` `source_gone`
|
||||
- **WHEN** её раздача снова появляется в qBittorrent и продвигается
|
||||
- **THEN** сверка восстановления её не трогает — задача остаётся в `failed` до
|
||||
ручного `Retry`
|
||||
@@ -0,0 +1,38 @@
|
||||
# Tasks — Пропажа источника у активной загрузки
|
||||
|
||||
## 1. Код воркера
|
||||
|
||||
- [x] 1.1 Ввести `errCodeSourceGone = "source_gone"` в блок кодов ошибок
|
||||
(`internal/worker/worker.go`) с комментарием: distinct-код, восстановлению
|
||||
сверкой не подлежит, retriable вручную.
|
||||
- [x] 1.2 Перестроить активный цикл в `Poll`: при промахе `torrentFor` прогонять
|
||||
`debounceSource`; при исчерпании порога — `transition downloading → failed`
|
||||
(`source_gone`); до порога — `continue`; при наличии источника — сброс счётчика
|
||||
и обычная сверка (`captureInfohashes`/`captureSourceAddedAt`/`reconcile`).
|
||||
- [x] 1.3 Убедиться, что `debounceSource` вызывается и при наличии источника
|
||||
(сброс накопленных промахов).
|
||||
- [x] 1.4 `Retry` сбрасывает `source_miss_count` в 0 (рядом с `SetRetriedAt`),
|
||||
чтобы retried `source_gone`-задача получила полное грейс-окно, а не падала
|
||||
сразу (см. design «Сброс source_miss_count при Retry»).
|
||||
|
||||
## 2. Тесты
|
||||
|
||||
- [x] 2.1 Промах < порога → задача остаётся `downloading`.
|
||||
- [x] 2.2 Промах ≥ порога → `downloading → failed`/`source_gone` + `EventFailed`.
|
||||
- [x] 2.3 Возврат раздачи до порога → счётчик сброшен, задача ведётся обычной
|
||||
сверкой.
|
||||
- [x] 2.4 `source_gone` не воскрешается `reconcileRecovery` (источник вернулся —
|
||||
задача остаётся `failed`).
|
||||
- [x] 2.5 `Retry` задачи `source_gone` сбрасывает `source_miss_count`: после
|
||||
retry с ещё-не-видимой раздачей задача остаётся `downloading` полный порог
|
||||
тиков (грейс-окно не съедено).
|
||||
|
||||
## 3. Документация
|
||||
|
||||
- [x] 3.1 `docs/specs/workflow.md`: добавить ребро `downloading → failed`
|
||||
(`source_gone`, пропажа источника) в диаграмму и список переходов.
|
||||
|
||||
## 4. Верификация
|
||||
|
||||
- [x] 4.1 `task test` / `task lint` зелёные.
|
||||
- [x] 4.2 `openspec validate downloading-source-gone --strict`.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-08
|
||||
@@ -0,0 +1,79 @@
|
||||
## Context
|
||||
|
||||
Приём дедуплицирует входящий источник по активной задаче двумя путями:
|
||||
|
||||
1. **Быстрый чек** — `Ingest` вызывает `FindActiveByInfohash`, при попадании
|
||||
уходит в `attached()` (дозапись недостающих хешей через `AddInfohashes`).
|
||||
Это основной путь.
|
||||
2. **Гонка** — быстрый чек пуст, но пока выводили имя/готовили запись,
|
||||
активная задача появилась; `CreateDownloadIfNoActive` внутри своей
|
||||
транзакции находит её и возвращает как дедуп (дозапись хешей внутри той же
|
||||
tx).
|
||||
|
||||
F1 живёт в пути 2 (неохраняемая дозапись). F6 задевает ОБА пути: в реальном
|
||||
сценарии первым отрабатывает быстрый чек (путь 1), поэтому апгрейд обязан
|
||||
работать и там.
|
||||
|
||||
## F1 — пер-хеш гард дозаписи в дедуп-ветке
|
||||
|
||||
**Решение.** В дедуп-ветке `CreateDownloadIfNoActive` заменяем безусловный
|
||||
цикл `INSERT OR IGNORE` на тот же гард, что в `AddInfohashes`: для каждого
|
||||
хеша `h` проверяем `findActiveByInfohash(ctx, tx, {h}, existing.ID)`; если
|
||||
другая активная задача владеет `h` — пропускаем (не дописываем), иначе
|
||||
`INSERT OR IGNORE`. Транзакция уже открыта, `excludeID = existing.ID`
|
||||
исключает саму дедуп-цель (её собственные хеши не конфликтуют с ней самой).
|
||||
|
||||
**Почему пропуск, а не ошибка.** Дедуп по контракту не падает — возвращает
|
||||
существующую задачу. Конфликтный хеш принадлежит другой активной задаче;
|
||||
молча его не трогаем — это и есть сохранение инварианта. В отличие от
|
||||
`AddInfohashes`, который сигналит `ErrInfohashTaken` вызывающему (там это
|
||||
осмысленно), у дедуп-ветки наблюдаемого канала ошибки нет и он не нужен:
|
||||
поведение — «присоединиться к найденной задаче, чужое не красть».
|
||||
|
||||
## F6 — апгрейд catched-magnet до torrent
|
||||
|
||||
**Решение.** Новый guarded-метод хранилища:
|
||||
|
||||
```
|
||||
UpgradeCatchedMagnetToTorrent(ctx, downloadID string, torrentBlob []byte) (bool, error)
|
||||
```
|
||||
|
||||
в одной write-транзакции:
|
||||
|
||||
1. Пустой `torrentBlob` → `(false, nil)` (защитный no-op).
|
||||
2. Гардированный UPDATE:
|
||||
`UPDATE download SET source_type='torrent', updated_at=?
|
||||
WHERE id=? AND source_type='magnet' AND state='catched'`.
|
||||
`RowsAffected==0` → задача не подходит (уже `downloading`/отменена/не
|
||||
magnet) → коммит без эффекта, `(false, nil)`.
|
||||
3. `RowsAffected==1` → `INSERT OR REPLACE INTO download_torrent(download_id,
|
||||
data)` (у magnet блоба нет; `OR REPLACE` — страховка идемпотентности),
|
||||
коммит, `(true, nil)`.
|
||||
|
||||
**Почему гард `state='catched'`.** Апгрейд имеет смысл только пока worker ещё
|
||||
не отдал источник в qBittorrent. В `catched` worker на шаге добавления
|
||||
выбирает способ по `source_type` (`internal/worker/worker.go:sourceAddParts`):
|
||||
после смены на `torrent` он добавит файлом — метаданные приедут сразу. Если
|
||||
задача уже `downloading`, magnet давно в qBittorrent (застрял в metaDL), и
|
||||
смена `source_type` его не переотдаст; это отдельная забота retry/desync, не
|
||||
приёма. Тот же паттерн ре-валидации, что у `PromoteCatched`: если задачу
|
||||
успели отменить, UPDATE не заденет строк и апгрейд просто не применится.
|
||||
|
||||
**Где вызываем.** Оба дедуп-пути `Ingest` сводим к `attached()` (в ветке
|
||||
гонки `existing` от `CreateDownloadIfNoActive` уже с подгруженными хешами, так
|
||||
что переиспользование безопасно). В `attached()` после дозаписи хешей: если
|
||||
входящий источник — `torrent` и есть байты, зовём
|
||||
`UpgradeCatchedMagnetToTorrent`. Вызов best-effort: ошибка/неуспех логируются
|
||||
`Warn`/`Info`, приём не валится (как и дозапись хешей). Результат приёма
|
||||
по-прежнему несёт `State` существующей задачи (остаётся `catched`) — меняется
|
||||
лишь способ будущего добавления.
|
||||
|
||||
**Идемпотентность и повтор.** Повторный `.torrent` того же хеша: первый
|
||||
апгрейд перевёл задачу в `torrent`, гард `source_type='magnet'` на втором даст
|
||||
`RowsAffected==0` → no-op. Байты уже сохранены при создании torrent-ветки —
|
||||
`OR REPLACE` перезапишет теми же данными без вреда.
|
||||
|
||||
## Границы
|
||||
|
||||
Схему не трогаем: `download_torrent` и колонка `source_type` уже есть. ER-схема
|
||||
`docs/specs/database.md` без изменений. Апгрейд — операция над данными.
|
||||
@@ -0,0 +1,56 @@
|
||||
## Why
|
||||
|
||||
Ревью приёма (Fable, 2026-07-08) вскрыло два дефекта в дедуп-ветках приёма,
|
||||
оба про инвариант «≤1 активная загрузка на infohash» и про сохранность
|
||||
источника:
|
||||
|
||||
- **F1 — неохраняемая дозапись хешей.** Дедуп-ветка
|
||||
`CreateDownloadIfNoActive` (`internal/store/download.go`) безусловно
|
||||
дописывает ВСЕ хеши входящего источника в найденную активную задачу
|
||||
(`INSERT OR IGNORE`) без пер-хеш гарда владения — в отличие от
|
||||
`AddInfohashes`, где гард есть. Это единственная неохраняемая запись хешей,
|
||||
и она в авторитетном методе инварианта. Сценарий: активная A владеет `v1`,
|
||||
активная B владеет `v2` того же гибридного торрента; приём гибрида
|
||||
`{v1,v2}`, дедупнувшись на B, допишет `v1` в B → две активные владеют `v1`.
|
||||
Инвариант нарушен.
|
||||
|
||||
- **F6 — потерянный upgrade-путь `.torrent` поверх magnet.** Пользователь
|
||||
сначала ловит magnet с закрытого трекера (задача `catched`,
|
||||
`source_type=magnet`), понимает, что без DHT метаданные не докачаются, и
|
||||
грузит правильный `.torrent`. Приём дедупит по infohash на magnet-задачу; по
|
||||
текущей спеке байты при дедупе НЕ сохраняются, `source_type` остаётся
|
||||
`magnet`. Worker добавляет раздачу по magnet-URL → вечный metaDL → failed.
|
||||
Ровно тот артефакт, который бы починил загрузку, выбрасывается с «уже в
|
||||
работе».
|
||||
|
||||
## What Changes
|
||||
|
||||
- **F1:** дедуп-ветка `CreateDownloadIfNoActive` применяет тот же пер-хеш
|
||||
гард, что и `AddInfohashes`: хеш, которым владеет ДРУГАЯ активная задача,
|
||||
не дописывается. Транзакция уже открыта — правка внутри неё.
|
||||
|
||||
- **F6:** при дедупе, где входящее — байты `.torrent`, а активная задача
|
||||
поймана как `magnet` и ещё не добавлена в qBittorrent (состояние
|
||||
`catched`), система сохраняет байты и меняет `source_type` на `torrent` в
|
||||
одной транзакции. Тогда worker добавит раздачу файлом и метаданные не
|
||||
придётся докачивать по DHT. Это ПРОТИВОРЕЧИТ действующему правилу спеки
|
||||
ingest «при дедупликации байты сохраняться SHALL NOT» — правило смягчается
|
||||
этим целевым исключением (MODIFIED-дельта).
|
||||
|
||||
Схема БД не меняется: таблица `download_torrent` уже есть, `source_type` —
|
||||
существующая колонка; апгрейд — изменение данных, не структуры.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
- `ingest`: уточняется поведение дедупа (пер-хеш гард дозаписи; сохранение
|
||||
`.torrent`-байт и смена источника при апгрейде `catched`-magnet).
|
||||
|
||||
## Impact
|
||||
|
||||
- Код: `internal/store/download.go` (гард F1 + новый guarded-метод апгрейда),
|
||||
`internal/ingest/ingest.go` (вызов апгрейда на обоих дедуп-путях).
|
||||
- Тесты: `internal/store`, `internal/ingest`.
|
||||
- Совместимость: изменение только ужесточает инвариант (F1) и добавляет
|
||||
целевой апгрейд (F6); существующие потоки без `.torrent`-поверх-magnet
|
||||
ведут себя как прежде.
|
||||
@@ -0,0 +1,154 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Атомарность возврата загрузки в активное состояние
|
||||
|
||||
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
|
||||
возвращающем загрузку из терминального состояния в активное (ручной retry,
|
||||
воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её
|
||||
(приём, adopt чужой раздачи), что никакая другая активная загрузка не
|
||||
владеет любым из хешей этой, и при владении SHALL отказывать в переходе,
|
||||
сохраняя инвариант «не более одной активной загрузки на infohash».
|
||||
Отказ SHALL происходить до побочных эффектов во внешних системах
|
||||
(повторного добавления торрента в qBittorrent).
|
||||
|
||||
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие
|
||||
гибридного торрента) на ВСЕХ путях дозаписи, включая дедуп-дозапись при
|
||||
приёме: хеш, которым владеет другая активная загрузка, дописан быть SHALL
|
||||
NOT — ни отдельным методом дозаписи, ни дедуп-веткой атомарного заведения,
|
||||
которая доносит недостающие хеши найденной активной задаче. Прямой перевод
|
||||
терминальной загрузки в активное состояние в обход этой проверки SHALL
|
||||
отклоняться хранилищем (механический бэкстоп вместо удалённого
|
||||
unique-индекса).
|
||||
|
||||
#### Scenario: Retry при занятом хеше
|
||||
|
||||
- **GIVEN** загрузка #1 в `failed` с хешем `h`, и другая активная загрузка
|
||||
#2 с тем же `h`
|
||||
- **WHEN** пользователь вызывает retry для #1
|
||||
- **THEN** переход отклоняется с пояснением, #1 остаётся в `failed`
|
||||
- **AND** активной по `h` остаётся #2
|
||||
|
||||
#### Scenario: Дедуп-дозапись не крадёт чужой хеш
|
||||
|
||||
- **GIVEN** активная загрузка A владеет хешем `v1`, активная загрузка B
|
||||
владеет хешем `v2` того же гибридного торрента
|
||||
- **WHEN** принимается источник с обоими хешами `{v1, v2}` и дедупится на B
|
||||
- **THEN** B получает только незанятые хеши, а `v1` (в собственности A) B не
|
||||
дописывается
|
||||
- **AND** инвариант «не более одной активной загрузки на infohash»
|
||||
сохраняется (по `v1` активна только A)
|
||||
|
||||
### Requirement: Приём источника из .torrent-файла
|
||||
|
||||
Приём SHALL принимать источник в виде **байтов `.torrent`-файла** (наряду с
|
||||
magnet-ссылкой) — тем же быстрым use-case, общим для транспортов. Получив
|
||||
непустые байты торрента, система SHALL разобрать их локально (без сети),
|
||||
извлечь инфохэш(и) и завести загрузку с `source_type = torrent`, после чего
|
||||
сразу вернуть ответ транспорту (синхронный путь к qBittorrent не обращается —
|
||||
добавление делает воркер, см. `download-tracking`).
|
||||
|
||||
Инфохэши система SHALL извлекать такими, какими их сообщает qBittorrent, чтобы
|
||||
сопоставление раздач и дедупликация работали: v1-хеш (для v1/гибридного файла)
|
||||
SHALL вычисляться как SHA1 **исходных** байтов info-словаря (без переэнкода);
|
||||
v2-хеш (для v2/гибридного файла, BEP52) SHALL извлекаться как 64-hex `infohash_v2`.
|
||||
Для чистого v2-only файла система SHALL записывать v2-хеш (v1 у него нет).
|
||||
Извлечение всех известных хешей и дозапись недостающих подчиняются требованию
|
||||
«Множество инфохэшей загрузки».
|
||||
|
||||
Дедупликацию по активной задаче, атомарное заведение (`download` в состоянии
|
||||
`catched` + записи `download_infohash`) и инвариант «не более одной активной
|
||||
загрузки на infohash» torrent-приём SHALL проходить тем же атомарным путём, что
|
||||
и magnet (см. «Приём источника и заведение загрузки», «Дедупликация приёма по
|
||||
любому из хешей», «Атомарность возврата загрузки в активное состояние»).
|
||||
|
||||
Байты `.torrent` система SHALL сохранять персистентно, привязанными к загрузке,
|
||||
чтобы воркер мог добавить источник в qBittorrent именно файлом (не по magnet):
|
||||
раздачи закрытых трекеров и торренты без DHT по magnet-хешу метаданные не
|
||||
получат. Сохранение байтов SHALL выполняться в той же write-транзакции, что и
|
||||
заведение загрузки; при дедупликации (новая загрузка не создана) байты в общем
|
||||
случае сохраняться SHALL NOT.
|
||||
|
||||
**Исключение — апгрейд пойманной magnet-задачи до torrent.** Если входящий
|
||||
источник — байты `.torrent`, а дедуп попал на активную загрузку с
|
||||
`source_type = magnet`, ещё НЕ отданную в qBittorrent (состояние `catched`),
|
||||
система SHALL в одной write-транзакции сохранить байты `.torrent`,
|
||||
привязав их к этой загрузке, и сменить её `source_type` на `torrent`. Тем
|
||||
самым воркер добавит раздачу файлом, а не magnet-хешем (иначе на закрытом
|
||||
трекере без DHT метаданные не докачаются, а magnet застрянет в metaDL →
|
||||
failed). Апгрейд SHALL применяться ТОЛЬКО пока загрузка в `catched` (воркер
|
||||
источник ещё не добавил); для уже добавленной (`downloading` и далее)
|
||||
загрузки смена `source_type` при дедупе выполняться SHALL NOT — её судьба
|
||||
решается путями retry/сверки, а не приёмом. Апгрейд SHALL быть best-effort:
|
||||
его неуспех приём не прерывает.
|
||||
|
||||
Из полей `.torrent` система SHALL синтезировать контекст распознавания (имя
|
||||
раздачи, суммарный размер, сигнал по дереву файлов, домен трекера, комментарий)
|
||||
и **дополнять** им контекст транспорта — тем же правилом слияния, что и синтез
|
||||
из полей magnet (пользовательский текст первым; при пустом тексте — только
|
||||
синтез). Обогащённый контекст система SHALL сохранять в `download.Context`.
|
||||
Синтез SHALL выполняться без сетевых запросов.
|
||||
|
||||
`source_ref` у torrent-загрузки SHALL быть человекочитаемым референсом (имя
|
||||
раздачи или файла), а НЕ адресом добавления: добавление в qBittorrent идёт
|
||||
байтами, и трактовать `source_ref` как magnet/URL для добавления система SHALL
|
||||
NOT.
|
||||
|
||||
#### Scenario: Быстрый приём .torrent-файла
|
||||
|
||||
- **GIVEN** валидные байты `.torrent`-файла и (опц.) текст контекста
|
||||
- **WHEN** вызывается приём
|
||||
- **THEN** из файла извлекаются инфохэши и создаётся `download` в состоянии
|
||||
`catched` (`source_type = torrent`) с записями `download_infohash`
|
||||
- **AND** байты файла сохраняются привязанными к загрузке
|
||||
- **AND** ответ транспорту отдан без обращения к qBittorrent
|
||||
|
||||
#### Scenario: Инфохэш из исходных байтов info
|
||||
|
||||
- **WHEN** система разбирает v1/гибридный `.torrent`-файл
|
||||
- **THEN** инфохэш v1 вычисляется как SHA1 исходных байтов info-словаря
|
||||
- **AND** совпадает с хешем, по которому qBittorrent позже сопоставит раздачу
|
||||
|
||||
#### Scenario: v2-only файл записывается под v2-хешем
|
||||
|
||||
- **WHEN** система разбирает `.torrent` только с метаданными v2 (без v1)
|
||||
- **THEN** у загрузки записывается v2-хеш (64-hex), совпадающий с `infohash_v2`
|
||||
qBittorrent
|
||||
- **AND** сопоставление раздачи работает по нему
|
||||
|
||||
#### Scenario: Дубль .torrent по активной torrent-задаче
|
||||
|
||||
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash и
|
||||
`source_type = torrent`
|
||||
- **WHEN** принимается `.torrent` с тем же инфохэшем
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||
- **AND** байты торрента повторно не сохраняются (дубль)
|
||||
|
||||
#### Scenario: Апгрейд catched-magnet до torrent
|
||||
|
||||
- **GIVEN** активная загрузка в `catched` с `source_type = magnet` и хешем `h`
|
||||
(magnet-задача ещё не отдана в qBittorrent)
|
||||
- **WHEN** принимается `.torrent` с тем же инфохэшем `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||
- **AND** байты `.torrent` сохраняются привязанными к ней, а её `source_type`
|
||||
становится `torrent` — в одной транзакции
|
||||
- **AND** воркер добавит раздачу файлом (не по magnet)
|
||||
|
||||
#### Scenario: Magnet-задача уже добавлена — апгрейда нет
|
||||
|
||||
- **GIVEN** активная загрузка с `source_type = magnet` уже в `downloading`
|
||||
(отдана в qBittorrent)
|
||||
- **WHEN** принимается `.torrent` с тем же инфохэшем
|
||||
- **THEN** возвращается существующая загрузка, её `source_type` остаётся
|
||||
`magnet`, байты `.torrent` не сохраняются
|
||||
|
||||
#### Scenario: Контекст из полей файла
|
||||
|
||||
- **WHEN** принят `.torrent` с именем раздачи, деревом файлов и трекерами
|
||||
- **THEN** в `download.Context` добавляется синтез (имя, размер, сигнал по
|
||||
файлам, домен трекера), дополняющий текст транспорта
|
||||
- **AND** синтез выполнен без сетевых запросов
|
||||
|
||||
#### Scenario: Слишком большой .torrent отклоняется
|
||||
|
||||
- **WHEN** принимаемый `.torrent`-файл превышает ограничение размера
|
||||
- **THEN** приём отклоняется с ошибкой, загрузка не создаётся
|
||||
@@ -0,0 +1,29 @@
|
||||
## 1. F1 — пер-хеш гард дедуп-дозаписи
|
||||
|
||||
- [x] 1.1 В `CreateDownloadIfNoActive` (`internal/store/download.go`) заменить
|
||||
безусловный цикл `INSERT OR IGNORE` дедуп-ветки на пер-хеш гард как в
|
||||
`AddInfohashes`: пропускать хеш, которым владеет другая активная задача
|
||||
(`findActiveByInfohash(..., existing.ID)`), внутри уже открытой tx.
|
||||
- [x] 1.2 Тест в `internal/store`: гибридный дедуп на B не крадёт хеш,
|
||||
принадлежащий активной A (инвариант сохранён).
|
||||
|
||||
## 2. F6 — апгрейд catched-magnet до torrent
|
||||
|
||||
- [x] 2.1 Добавить guarded-метод хранилища
|
||||
`UpgradeCatchedMagnetToTorrent(ctx, downloadID, torrentBlob) (bool, error)`:
|
||||
в одной tx гардированным UPDATE `source_type='torrent'` при
|
||||
`source_type='magnet' AND state='catched'`, затем сохранить байты в
|
||||
`download_torrent`; вернуть, был ли апгрейд.
|
||||
- [x] 2.2 В `internal/ingest/ingest.go` свести оба дедуп-пути к `attached()` и
|
||||
вызвать апгрейд, когда входящий источник — torrent с байтами
|
||||
(best-effort: неуспех логируется, приём не валится).
|
||||
- [x] 2.3 Обновить интерфейс `ingest.Store` и `fakeStore` в тестах.
|
||||
- [x] 2.4 Тесты в `internal/store`: апгрейд из `catched`+magnet сохраняет байты
|
||||
и меняет `source_type`; из `downloading`/не-magnet — no-op. Тест в
|
||||
`internal/ingest`: дедуп `.torrent` на catched-magnet вызывает апгрейд.
|
||||
|
||||
## 3. Проверки
|
||||
|
||||
- [x] 3.1 `openspec validate --strict ingest-dedup-integrity`
|
||||
- [x] 3.2 `task test`
|
||||
- [x] 3.3 `task lint`
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-08
|
||||
@@ -0,0 +1,72 @@
|
||||
# Design
|
||||
|
||||
## Контекст
|
||||
|
||||
`linking` — короткое рабочее состояние между «решили раскладывать» и
|
||||
«разложили». Оно нетерминально и активно, но в отличие от `recognizing` его
|
||||
никто не листит на рестарте, а переход в него — обычный `SetDownloadState`, чей
|
||||
сбой раньше проглатывался. Обе дыры (MINOR-7, MAJOR-4) — про то, что `linking`
|
||||
не был устойчивым владельцем шага.
|
||||
|
||||
## Решение 1: `transition` возвращает ошибку — но только там, где она нужна
|
||||
|
||||
Параллельный поток правит соседние функции воркера (`Retry`/`checkTimeouts`/
|
||||
`torrentAge`), поэтому смена сигнатуры `transition` на всех ~20 вызовах
|
||||
(с добавлением `_ =` в fire-and-forget местах) создала бы лишние конфликты
|
||||
слияния и шум. Вместо этого:
|
||||
|
||||
- `transition(...)` остаётся `void` — обёртка, гасящая ошибку. Все существующие
|
||||
вызовы (reconcile, таймауты, команды ревью, финальные переходы `linkPlan`,
|
||||
sweep) не трогаются: за ними НЕТ побочного эффекта, зависящего от факта записи
|
||||
claim, — переход и есть конец шага.
|
||||
- `transitionErr(...)` — новая функция, тело прежнего `transition` + `return
|
||||
error`. Пинги/скан живут в ней (обёртка делегирует).
|
||||
|
||||
**Развилка:** менять сигнатуру `transition` глобально (честнее, но шумно и
|
||||
конфликтно) против точечного `transitionErr` (`mustTransition` из ревью). Выбран
|
||||
точечный вариант: минимальный след, локальные правки по функциям, ошибка
|
||||
возвращается ровно там, где за claim следует побочный эффект.
|
||||
|
||||
Использование: `Apply` и авто-раскладка в `finishRecognition` зовут
|
||||
`transitionErr(StateLinking)` и прерываются при ошибке ДО `linkPlan`. При провале
|
||||
claim `Apply` остаётся в `review`/`deferred`, а авто-путь — в `recognizing`
|
||||
(его повторит `recognizePending`); в обоих случаях владелец шага сохраняется.
|
||||
|
||||
## Решение 2: провал `CreateFileLinks` уводит в `review`, а не оставляет в `linking`
|
||||
|
||||
Хардлинки к этому моменту уже на диске — это учётный, а не безопасностный сбой
|
||||
(файлы разложены). Оставлять задачу в `linking` нельзя (осиротеет до sweep, а до
|
||||
того файлы висят без `file_link`). Уводим в `review` с кодом `persist`: повторный
|
||||
`Apply` идемпотентен — `layout.Apply` вернёт `StatusExists` на уже созданных
|
||||
ссылках, а `CreateFileLinks` допишет учёт.
|
||||
|
||||
**Почему `review`, а не `failed`:** план валиден, сбой транзиентный, самолечение
|
||||
через повтор естественно ложится в петлю ревью (как коллизия). `failed` уводил
|
||||
бы в восстановление сверкой, которое к этому кейсу не относится.
|
||||
|
||||
Соседний сбой `SupersedeForeignLinks` уже трактуется как учётный (WARN, доводим
|
||||
до `done`) — тот кейс не меняем: там файлы разложены И учтены, чужой рассинхрон
|
||||
починит следующий тик сверки.
|
||||
|
||||
## Решение 3: sweep осиротевшего `linking` на тике и старте
|
||||
|
||||
Новый шаг `sweepLinking` в `pollOnce` (выполняется и первым вызовом до цикла —
|
||||
это «старт»). Берёт `w.mu`, листит `linking`, каждую переводит `linking → review`
|
||||
(ребро уже в графе) с кодом `interrupted`.
|
||||
|
||||
**Ключ корректности:** активная раскладка (`linkPlan`) держит `w.mu` на весь свой
|
||||
срок и завершает переход ИЗ `linking` до отпускания замка. Значит любая
|
||||
`linking`-задача, которую `sweepLinking` видит, взяв `w.mu`, гарантированно НЕ
|
||||
в полёте — она осталась после краша между claim и финальным переходом. Ложных
|
||||
срабатываний на живой раскладке нет.
|
||||
|
||||
Так `linking` получает владельца на рестарте/тике — по образцу `recognizing`
|
||||
(`recognizePending`); инвариант «у каждого нетерминального состояния есть
|
||||
владелец» восстановлен.
|
||||
|
||||
## Что НЕ делаем
|
||||
|
||||
- Не трогаем граф переходов: ребро `linking → review` уже объявлено в
|
||||
`allowedTransitions`.
|
||||
- Не меняем схему БД.
|
||||
- Не меняем сигнатуру `transition` глобально (см. Решение 1).
|
||||
@@ -0,0 +1,61 @@
|
||||
## Why
|
||||
|
||||
Раскладка хардлинками устроена как «claim-then-side-effect»: сначала задача
|
||||
переводится в `linking` (claim владения шагом), затем создаются хардлинки и
|
||||
пишется их учёт (`file_link`). Ревью жизненного цикла (Fable, 2026-07-08)
|
||||
нашло две связанные дыры устойчивости этого пути.
|
||||
|
||||
- **MINOR-7:** `worker.transition` при ошибке записи состояния логировал её, но
|
||||
НЕ возвращал вызывающему. На путях `Apply` и авто-раскладки в
|
||||
`finishRecognition` выполнение продолжалось к побочным эффектам: хардлинки
|
||||
создавались, пока claim перехода в `linking` не закоммичен. Финальный переход
|
||||
`linking → done` оценивался графом как `review → done` (нелегальное ребро) и
|
||||
отклонялся — задача застревала в `review` со stale-планом, скан/уведомление не
|
||||
срабатывали.
|
||||
- **MAJOR-4:** задача может осиротеть в `linking`:
|
||||
(A) без краха — хардлинки созданы, но `CreateFileLinks` упал транзиентно
|
||||
(SQLite busy) → голый `return` оставлял задачу в `linking`, а файлы на диске —
|
||||
без строк `file_link`;
|
||||
(B) краш процесса между переходом в `linking` и финальным переходом → на
|
||||
рестарте `linking` не листит НИКТО (поллинг листит `downloading`, распознавание
|
||||
— `completed`/`recognizing`, сверка — `done`/`target_missing`/`orphaned`,
|
||||
восстановление — `failed`/`stuck`). Задача сидит в `linking` вечно; выход —
|
||||
только ручной Cancel/Defer (недискаверабельно). Нарушен инвариант «у каждого
|
||||
нетерминального состояния есть владелец» (`recognizing` уже лечится
|
||||
рестартом через `recognizePending`, `linking` — нет).
|
||||
|
||||
## What Changes
|
||||
|
||||
- `transition` разделяется на fire-and-forget обёртку (прежнее имя, прежнее
|
||||
поведение для reconcile/таймаутов/финальных переходов) и `transitionErr`,
|
||||
которая ВОЗВРАЩАЕТ ошибку записи. На claim-then-side-effect путях (`Apply`,
|
||||
авто-раскладка в `finishRecognition`) провал claim перехода в `linking` теперь
|
||||
прерывает выполнение ДО хардлинков.
|
||||
- В `linkPlan` провал `CreateFileLinks` больше не оставляет задачу в `linking`:
|
||||
задача уходит в `review` с кодом `persist` и причиной; повторный `Apply`
|
||||
идемпотентен (хардлинки уже на диске → `StatusExists`, учёт дописывается).
|
||||
- Новый шаг поллинга `sweepLinking`: на каждом тике и на старте задачи в
|
||||
`linking` возвращаются в `review` с кодом `interrupted` и причиной
|
||||
«прерванная раскладка, повтори применение». Любая `linking`, видимая под
|
||||
`w.mu`, устарела по построению (активная раскладка держит `w.mu` весь свой
|
||||
срок), значит осталась после краха.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `file-layout`: раскладка становится устойчивой к сбою записи claim/учёта —
|
||||
хардлинки не создаются при незакоммиченном claim, а сбой записи учёта не
|
||||
стрэндит задачу в `linking`.
|
||||
- `state-reconciliation`: у нетерминального `linking` появляется владелец на
|
||||
рестарте/тике — sweep осиротевших `linking` в `review`.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Код:** `internal/worker/worker.go` (`transition`/`transitionErr`, `pollOnce`,
|
||||
`sweepLinking`), `internal/worker/review.go` (`Apply`, `finishRecognition`,
|
||||
`linkPlan`). Граф переходов (`internal/store/download.go`) правки не требует —
|
||||
ребро `linking → review` уже объявлено.
|
||||
- **Тесты:** провал claim прерывает до хардлинков; провал `CreateFileLinks`
|
||||
уводит в `review` (файлы на диске); sweep осиротевшего `linking` → `review`.
|
||||
- **БД/схема:** без изменений.
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Claim раскладки коммитится до хардлинков и устойчив к сбою учёта
|
||||
|
||||
Раскладка — «claim-then-side-effect»: система SHALL сперва зафиксировать переход
|
||||
задачи в `linking` (claim шага раскладки), и только затем создавать хардлинки.
|
||||
Если запись claim перехода в `linking` провалилась, система НЕ SHALL создавать
|
||||
хардлинки и SHALL прервать раскладку, оставив задачу в исходном состоянии
|
||||
(`review`/`deferred` при ручном применении; `recognizing` при авто-раскладке) —
|
||||
чтобы у шага сохранился владелец, а хардлинки не легли при незакоммиченном claim
|
||||
(иначе финальный переход `linking → done` из фактического состояния был бы
|
||||
отклонён графом, и задача застряла бы со stale-планом).
|
||||
|
||||
Если хардлинки уже созданы, но запись их учёта (`file_link`) провалилась
|
||||
(транзиентная ошибка хранилища), задача НЕ SHALL оставаться в `linking`: система
|
||||
SHALL перевести её в `review` с причиной. Повторное применение SHALL быть
|
||||
идемпотентным — уже созданные хардлинки распознаются как существующие
|
||||
(`StatusExists`), а их учёт дописывается.
|
||||
|
||||
#### Scenario: Провал claim не создаёт хардлинков
|
||||
|
||||
- **GIVEN** задача в `review` с готовым источником и валидным планом
|
||||
- **WHEN** запись перехода в `linking` проваливается
|
||||
- **THEN** хардлинки не создаются, учёт `file_link` не пишется
|
||||
- **AND** задача остаётся в `review`, а команда отказывает с ошибкой
|
||||
|
||||
#### Scenario: Провал учёта уводит в review, не оставляя в linking
|
||||
|
||||
- **GIVEN** хардлинки по плану уже созданы на файловой системе
|
||||
- **WHEN** запись строк `file_link` проваливается транзиентной ошибкой
|
||||
- **THEN** задача переходит в `review` с причиной (код `persist`), а не остаётся
|
||||
в `linking`
|
||||
- **AND** созданные хардлинки остаются на диске
|
||||
- **AND** повторное «Применить» идемпотентно дописывает учёт и доводит до `done`
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Восстановление задачи, застрявшей в linking
|
||||
|
||||
Система SHALL на каждом тике поллинга и при старте выявлять задачи в состоянии
|
||||
`linking` и возвращать их в `review` с причиной «прерванная раскладка» (код
|
||||
`interrupted`), откуда человек повторит применение (повтор идемпотентен).
|
||||
`linking` — нетерминальное активное состояние, и у него, как у каждого
|
||||
нетерминального состояния, ДОЛЖЕН быть владелец, продвигающий задачу; иначе
|
||||
краш процесса между переходом в `linking` и финальным переходом оставил бы
|
||||
задачу без владельца — её не листит ни один штатный шаг (ни поллинг активных,
|
||||
ни распознавание, ни матрица сверки, ни восстановление `failed`/`stuck`).
|
||||
|
||||
Выявление SHALL выполняться под той же блокировкой переходов, что и раскладка:
|
||||
активная раскладка удерживает блокировку весь свой срок и завершает переход из
|
||||
`linking` до её отпускания, поэтому любая `linking`-задача, наблюдаемая под
|
||||
блокировкой, по построению устарела (осталась после краха) — восстановление НЕ
|
||||
SHALL задевать раскладку в полёте.
|
||||
|
||||
#### Scenario: Осиротевший linking возвращается в review
|
||||
|
||||
- **GIVEN** задача осталась в `linking` после краха между claim и финальным
|
||||
переходом
|
||||
- **WHEN** выполняется тик поллинга (или старт сервиса)
|
||||
- **THEN** задача переходит в `review` с причиной «прерванная раскладка»
|
||||
(код `interrupted`)
|
||||
- **AND** её можно повторно применить из ревью
|
||||
|
||||
#### Scenario: Прочие состояния sweep не задевает
|
||||
|
||||
- **GIVEN** задачи в состояниях `done` и `review`
|
||||
- **WHEN** выполняется тик поллинга
|
||||
- **THEN** восстановление `linking` их состояние не меняет
|
||||
@@ -0,0 +1,34 @@
|
||||
## 1. Возврат ошибки перехода (MINOR-7)
|
||||
|
||||
- [x] 1.1 Разделить `transition` на `void`-обёртку и `transitionErr`
|
||||
(возвращает ошибку записи); пинги/скан — в `transitionErr`
|
||||
- [x] 1.2 `Apply`: заменить claim `transition(StateLinking)` на
|
||||
`transitionErr` с прерыванием до `linkPlan` при ошибке
|
||||
- [x] 1.3 `finishRecognition` (авто-раскладка): то же — при провале claim
|
||||
остаёмся в `recognizing`, `linkPlan` не зовём
|
||||
|
||||
## 2. Провал учёта не оставляет в linking (MAJOR-4 A)
|
||||
|
||||
- [x] 2.1 В `linkPlan` при провале `CreateFileLinks` перевести задачу в
|
||||
`review` (код `persist`) вместо голого `return`
|
||||
|
||||
## 3. Sweep осиротевшего linking (MAJOR-4 B)
|
||||
|
||||
- [x] 3.1 Добавить `sweepLinking`: под `w.mu` листить `linking` и переводить
|
||||
`linking → review` (код `interrupted`, причина «прерванная раскладка,
|
||||
повтори применение»)
|
||||
- [x] 3.2 Вызвать `sweepLinking` в `pollOnce` (тик + старт)
|
||||
|
||||
## 4. Тесты
|
||||
|
||||
- [x] 4.1 Провал claim перехода в `linking` прерывает `Apply` до хардлинков
|
||||
(нет файлов на диске, нет `file_link`, состояние `review`)
|
||||
- [x] 4.2 Провал `CreateFileLinks` уводит в `review` (файлы на диске есть,
|
||||
код `persist`)
|
||||
- [x] 4.3 `sweepLinking` переводит осиротевший `linking` в `review`
|
||||
(код `interrupted`); прочие состояния не задевает
|
||||
|
||||
## 5. Проверки
|
||||
|
||||
- [x] 5.1 `task test` и `task lint` проходят
|
||||
- [x] 5.2 `openspec validate linking-transition-robustness --strict` проходит
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-08
|
||||
@@ -0,0 +1,104 @@
|
||||
# Design: retry/stall basis
|
||||
|
||||
## Контекст
|
||||
|
||||
Два таймаута зависания в `Worker.checkTimeouts` сегодня используют один базис —
|
||||
возраст торрента `now − added_on`:
|
||||
|
||||
- `magnet_timeout`: `metaDL` дольше порога → `failed/magnet_timeout`.
|
||||
- `stuck_after`: `stalledDL` дольше порога → `stuck/stalled`.
|
||||
|
||||
Для `magnet_timeout` возраст семантически верен (сколько торрент вообще висит
|
||||
без метаданных). Для `stuck_after` возраст НЕВЕРЕН: нас интересует **простой**
|
||||
(сколько данные не двигаются), а не общий возраст (MAJOR-2). Отдельно retry
|
||||
живого торрента не сбрасывает базис, и задача мгновенно снова падает (MAJOR-1).
|
||||
|
||||
## Дизайн-развилка: откуда брать базис простоя/таймаута
|
||||
|
||||
Ключевое решение change — где взять базис для двух мер. Рассмотрены варианты:
|
||||
|
||||
- **(a) Новые колонки БД** `retried_at` и/или `stalled_since`. Базис возраста =
|
||||
`max(added_on, retried_at)`; простой — от `stalled_since` (момент входа в
|
||||
`stalledDL`, который мы сами детектируем и пишем/сбрасываем на каждом тике).
|
||||
- **(b) Переиспользовать `last_activity` из снимка торрента** для измерения
|
||||
простоя, без колонки на stall.
|
||||
- **(c) Гибрид (ВЫБРАН):** колонка `retried_at` (только для сброса базиса при
|
||||
ручном retry) + `last_activity` qBittorrent (для измерения простоя). Колонки
|
||||
`stalled_since` НЕТ.
|
||||
|
||||
### Выбор: (c) `retried_at` (БД) + `last_activity` (qBit)
|
||||
|
||||
**Простой мерим по `last_activity`, а не по `stalled_since`-колонке.**
|
||||
qBittorrent уже отдаёт `last_activity` (Unix-время последнего движения данных)
|
||||
в том же ответе `/torrents/info` — это авторитетный источник «сколько простой»
|
||||
прямо из движка. `stalled_since` дублировал бы это состояние, требовал бы
|
||||
детектировать переход «вход в stalledDL», писать/сбрасывать колонку на КАЖДОМ
|
||||
тике (торренты мерцают `stalledDL`↔`downloading`) и рисковал бы разъездом с
|
||||
собственным взглядом qBittorrent. Простой = `now − last_activity`: торрент,
|
||||
двигавший данные секунду назад, простаивает ~0 несмотря на возраст 5ч →
|
||||
MAJOR-2 закрыт без схемы для stall. Это часть варианта (b).
|
||||
|
||||
**Сброс базиса при retry храним в `retried_at` (БД), а не в памяти.** Спека
|
||||
требует, чтобы retry давал свежее окно и задача не падала снова. `last_activity`
|
||||
этого не выражает: у по-настоящему простаивающего торрента она «часы назад», и
|
||||
возврат в `downloading` тут же дал бы `stuck` на следующем тике. Нужна
|
||||
персистентная метка «пользователь нажал retry в момент T», которая приподнимает
|
||||
пол ОБОИХ базисов: `basis = max(добавление|last_activity, retried_at)`. Она
|
||||
должна пережить интервал поллинга и рестарт процесса (retry, затем рестарт не
|
||||
должен ронять задачу), поэтому — колонка, а не in-memory map. Это часть варианта
|
||||
(a), но минимальная: одна nullable TEXT-колонка. Закрывает MAJOR-1.
|
||||
|
||||
### Почему не чистые (a) или (b)
|
||||
|
||||
- **Чистый (b) без колонки** — нельзя записать `last_activity` qBittorrent, так
|
||||
что retry не смог бы сдвинуть базис → MAJOR-1 не решается.
|
||||
- **Чистый (a) со `stalled_since`** — лишняя колонка + пер-тиковая
|
||||
бухгалтерия входа/выхода из `stalledDL`, дублирующая `last_activity`. Отвергнут
|
||||
на минимальности схемы и единственном источнике истины.
|
||||
|
||||
### Замечание об интеграции (точка человеческого вето)
|
||||
|
||||
Change читает НОВОЕ поле qBittorrent `last_activity`, но НЕ добавляет нового
|
||||
вызова API или интеграционной поверхности — поле уже приходит в ответе
|
||||
`/torrents/info`, парсим на одно поле больше. Это единственная «интеграция»
|
||||
change, и она безопасна. Более глубокая интеграция для полного решения NIT-12
|
||||
(см. ниже) СОЗНАТЕЛЬНО отложена как точка человеческого вето.
|
||||
|
||||
## Итоговая схема базисов
|
||||
|
||||
```
|
||||
magnetAge = now − max( added_on | created_at(fallback), retried_at ) // magnet_timeout
|
||||
stallIdle = now − max( last_activity | added_on|created_at(fallback), retried_at ) // stuck_after
|
||||
```
|
||||
|
||||
- `addedBasis(d,t)` — `added_on`, иначе `created_at` (NIT-10), иначе базис
|
||||
неизвестен (WARN, таймаут не срабатывает).
|
||||
- `retriedFloor(d,basis)` — приподнимает базис до `retried_at`, если он позже.
|
||||
- `retried_at` не чистится: как только данные двинулись, `last_activity`
|
||||
естественно обгоняет `retried_at`, и пол перестаёт влиять.
|
||||
|
||||
## NIT-12: retry сломанного живого торрента
|
||||
|
||||
Живой торрент в `error`/`missingFiles` (класс `classErrored`) — перецепка к
|
||||
нему бессмысленна: reconcile на ближайшем тике вернёт задачу в `failed`. Retry
|
||||
теперь считает такой торрент «неживым для целей перецепки» и идёт по ветке
|
||||
повторного `Add` (повторно отдаёт источник). Это честнее слепой перецепки:
|
||||
retry перецепляется только к ЗДОРОВОМУ живому торренту.
|
||||
|
||||
**Остаточное ограничение (отложено, точка вето):** для устойчиво сломанного
|
||||
торрента повторный `Add` того же infohash qBittorrent, как правило, дедуплицирует
|
||||
— ошибка не очистится, и следующий тик всё равно вернёт задачу в `failed`. Полное
|
||||
устранение (принудительный recheck / delete+re-add через qBittorrent) требует
|
||||
НОВОЙ интеграции с клиентом и вынесено за рамки change на человеческое решение.
|
||||
|
||||
## Тесты
|
||||
|
||||
- `TestRetryResetsTimeoutBasis` — MAJOR-1: retry живого stalledDL-торрента с
|
||||
давним `added_on` и давним `last_activity`, затем СЛЕДУЮЩИЙ тик Poll →
|
||||
остаётся `downloading` (без сброса базиса ушёл бы в `stuck`). Именно эту
|
||||
регрессию прячет `TestRetryReattachesNoReadd`.
|
||||
- `TestStallMeasuredFromLastActivity` — MAJOR-2: `stalledDL` с давним `added_on`,
|
||||
но свежим `last_activity` → `downloading`; контроль — давняя `last_activity` →
|
||||
`stuck`.
|
||||
- `TestSetRetriedAtOverwrites` — store: `retried_at` перезаписывается (в отличие
|
||||
от однократного `source_added_at`).
|
||||
@@ -0,0 +1,78 @@
|
||||
## Why
|
||||
|
||||
Два связанных бага в семантике таймаутов зависания и ручного retry делают
|
||||
повседневные сценарии сломанными:
|
||||
|
||||
- **MAJOR-1 — retry живого торрента мгновенно снова падает.** `Worker.Retry`
|
||||
при живой раздаче (`alive=true`) не переиздаёт `Add`, а лишь возвращает
|
||||
задачу в `downloading`. Базис отсчёта таймаута (`age = now − added_on`) при
|
||||
этом НЕ сбрасывается. Если торрент давно добавлен/давно простаивает,
|
||||
ближайший тик снова видит `stalledDL && age > stuck_after` → задача опять
|
||||
уходит в `stuck` (~секунды). Спека `state-reconciliation` «Ручной повтор»
|
||||
требует сброса базиса, но код его не выполняет (комментарий «базис от
|
||||
added_on» верен лишь для ветки повторного `Add`). Существующий тест
|
||||
`TestRetryReattachesNoReadd` прячет баг, ставя `added_on` «минуту назад».
|
||||
|
||||
- **MAJOR-2 — `stuck_after` мерит ВОЗРАСТ, а не ПРОСТОЙ.** `checkTimeouts`
|
||||
считает `stalledDL`-таймаут от `added_on` (возраст торрента). Торрент,
|
||||
качавшийся 5 часов и на один тик зашедший в `stalledDL` (нормальный проход
|
||||
между пирами), мгновенно получает `stuck` со лживым сообщением «stalled for
|
||||
5h» и уведомление `EventFailed`. Результат — флап `stuck`↔`downloading` и
|
||||
до-часовые ложные пинги. Спека сама противоречива: «`stalledDL` дольше
|
||||
`stuck_after`» (простой) против «возраст от `added_on`».
|
||||
|
||||
Дополнительно закрываются два NIT из того же ревью:
|
||||
|
||||
- **NIT-10** — фолбэк базиса возраста `added_on → created_at` (когда qBit не
|
||||
отдал `added_on`) остаётся, но теперь явно документирован и покрыт.
|
||||
- **NIT-12** — retry задачи в `qbit_error` мгновенно откатывается: перецепка к
|
||||
сломанному (`error`/`missingFiles`) живому торренту бессмысленна — reconcile
|
||||
тут же возвращает задачу в `failed`. Retry перестаёт перецепляться к
|
||||
сломанному торренту и повторно отдаёт источник.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Мера простоя вместо возраста для `stuck_after`.** `stalledDL`-таймаут
|
||||
считается от `last_activity` qBittorrent (момент последнего движения данных),
|
||||
а не от возраста торрента. Долго качавшийся торрент со свежей активностью в
|
||||
`stuck` не уходит (MAJOR-2). `magnet_timeout` по-прежнему мерит **возраст**
|
||||
(`metaDL` без метаданных) от `added_on` — это семантически верно.
|
||||
- **Сброс базиса таймаутов при ручном retry.** Новая колонка `download.retried_at`
|
||||
(RFC 3339 UTC) фиксирует момент retry и приподнимает базис ОБОИХ таймаутов
|
||||
(`max(базис, retried_at)`). После retry задача получает свежее окно и не
|
||||
падает снова на ближайшем тике (MAJOR-1). Хранится в БД (не в памяти), чтобы
|
||||
сброс пережил интервал поллинга и рестарт процесса.
|
||||
- **Retry не перецепляется к сломанному торренту.** Если живой торрент в
|
||||
состоянии ошибки qBittorrent (`error`/`missingFiles`), retry повторно отдаёт
|
||||
источник вместо перецепки (NIT-12).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет. Семантика таймаутов зависания относится к жизненному циклу загрузки,
|
||||
который пока живёт в `docs/specs/workflow.md` (не мигрирован в OpenSpec).
|
||||
Нормативная правка `stuck_after`/`magnet_timeout` вносится туда; в OpenSpec
|
||||
затрагивается только `state-reconciliation` (восстановление и ручной retry).
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `state-reconciliation`: уточняется, что предотвращение `stuck` для
|
||||
прогрессирующего торрента опирается на **простой от `last_activity`**, а не
|
||||
на возраст; ручной retry сбрасывает базис таймаутов через `retried_at` и не
|
||||
перецепляется к сломанному живому торренту.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Спеки:** дельта `state-reconciliation` (2 MODIFIED requirements);
|
||||
правка семантики таймаутов и retry в `docs/specs/workflow.md` (источник
|
||||
истины по жизненному циклу до миграции).
|
||||
- **Код:** `internal/worker/worker.go` — `checkTimeouts` (две разные меры),
|
||||
`torrentAge`/новые `stallDuration`/`addedBasis`/`retriedFloor`, `Retry`
|
||||
(сброс базиса + перецепка только к здоровому торренту); `internal/qbt`
|
||||
(поле `last_activity`); `internal/store/download.go` (`RetriedAt`,
|
||||
`RetriedTime`, `SetRetriedAt`).
|
||||
- **Миграции БД:** `0010_retried_at.sql` — колонка `download.retried_at`;
|
||||
обновление ER-схемы `docs/specs/database.md`.
|
||||
- **qBittorrent-клиент:** читается новое поле `last_activity` из того же
|
||||
ответа `/torrents/info` (без нового вызова API).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user