Compare commits
5
Commits
2011fdd740
...
3d5df62d62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d5df62d62
|
||
|
|
c6daba46d9
|
||
|
|
84ffe0733e
|
||
|
|
9cfccc7b4a
|
||
|
|
c739a20749
|
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Памятка для работы над jellybit. Перед задачей прочитай также
|
Памятка для работы над jellybit. Перед задачей прочитай также
|
||||||
[README.md](README.md), [BRIEF.md](BRIEF.md) и
|
[README.md](README.md), [BRIEF.md](BRIEF.md) и
|
||||||
[docs/specs/architecture.md](docs/specs/architecture.md).
|
[docs/specs/architecture.md](docs/specs/architecture.md). Разработка идёт
|
||||||
|
по **Spec Driven Development** через OpenSpec — см. раздел ниже.
|
||||||
|
|
||||||
## Что это
|
## Что это
|
||||||
|
|
||||||
@@ -34,14 +35,57 @@
|
|||||||
перезаписываем.
|
перезаписываем.
|
||||||
- **Выход LLM недоверенный** — безопасность на валидации пути, не на
|
- **Выход LLM недоверенный** — безопасность на валидации пути, не на
|
||||||
промпте. Авто-раскладка только при подтверждённом матче в базе.
|
промпте. Авто-раскладка только при подтверждённом матче в базе.
|
||||||
|
- **Секреты не попадают в логи** — пароли qBittorrent, API-ключи LLM/метабаз,
|
||||||
|
auth-заголовки. Подробнее — [docs/conventions/logging.md](docs/conventions/logging.md).
|
||||||
- **Запуск:** контейнер под `1000:1000`, в общей docker-сети (адресация
|
- **Запуск:** контейнер под `1000:1000`, в общей docker-сети (адресация
|
||||||
по именам), mount `/srv/media` (единая песочница) + data-том для
|
по именам), mount `/srv/media` (единая песочница) + data-том для
|
||||||
SQLite/конфига.
|
SQLite/конфига.
|
||||||
|
|
||||||
## Документация: три раздела
|
## Spec Driven Development (OpenSpec)
|
||||||
|
|
||||||
- `docs/specs/` — **живые** спецификации целевого состояния. Меняем по
|
Изменения ведём через [OpenSpec](https://github.com/Fission-AI/OpenSpec)
|
||||||
мере развития, держим в соответствии с кодом.
|
(CLI `openspec`, v1.x). Сначала спецификация — потом код.
|
||||||
|
|
||||||
|
- `openspec/specs/<capability>/spec.md` — **актуальные** capability-спеки:
|
||||||
|
что система делает сейчас. Capability — это поведение/домен (`ingest`,
|
||||||
|
`recognition`, `file-layout`, `review`, `notifications`), а не пакет кода.
|
||||||
|
- `openspec/changes/<id>/` — предлагаемое изменение: `proposal.md` (зачем и
|
||||||
|
что), `design.md` (как, для нетривиальных), дельта-спеки (`ADDED`/
|
||||||
|
`MODIFIED`/`REMOVED Requirements`), `tasks.md` (шаги). После реализации
|
||||||
|
change архивируется в `openspec/changes/archive/`, дельты вливаются в
|
||||||
|
`openspec/specs/`.
|
||||||
|
- `openspec/config.yaml` — язык и правила оформления спек (читай перед
|
||||||
|
написанием).
|
||||||
|
|
||||||
|
Поток работы — через слэш-команды `opsx:*` (канонический набор, его
|
||||||
|
поддерживает `openspec update`): `opsx:explore` (продумать), `opsx:propose`
|
||||||
|
(завести change), `opsx:apply` (реализовать tasks), `opsx:sync`/`opsx:archive`
|
||||||
|
(влить и архивировать). Skills `openspec-*` — то же, но предыдущего
|
||||||
|
поколения; для новой работы используем `opsx:*`.
|
||||||
|
|
||||||
|
Правила спек:
|
||||||
|
|
||||||
|
- Каждое `### Requirement` ОБЯЗАНО содержать литерал `SHALL` или `MUST` —
|
||||||
|
иначе `openspec validate` падает.
|
||||||
|
- Структурные заголовки и ключевые слова — английские (`### Requirement:`,
|
||||||
|
`#### Scenario:`, `GIVEN/WHEN/THEN`, RFC 2119), остальной текст — русский.
|
||||||
|
- Сценарии — в формате `GIVEN/WHEN/THEN`.
|
||||||
|
- `openspec validate --strict` перед коммитом change.
|
||||||
|
|
||||||
|
Ревью (процесс, не артефакт): нетривиальная задача — два чекпоинта (ревью
|
||||||
|
дизайна после design/specs, ДО кода; ревью кода после apply, до archive);
|
||||||
|
тривиальная — одного прохода по коду достаточно.
|
||||||
|
|
||||||
|
**Миграция:** capabilities постепенно переносятся из `docs/specs/` в
|
||||||
|
OpenSpec (пилот — `ingest`). До переноса источник истины по теме —
|
||||||
|
соответствующий файл в `docs/specs/`; перенесённое живёт в
|
||||||
|
`openspec/specs/`.
|
||||||
|
|
||||||
|
## Прочая документация
|
||||||
|
|
||||||
|
- `docs/specs/` — **живые** спецификации целевого состояния (архитектурный
|
||||||
|
обзор + ещё не перенесённые в OpenSpec темы). Меняем по мере развития,
|
||||||
|
держим в соответствии с кодом.
|
||||||
- `docs/adr/` — **неизменяемый** журнал решений, пишется постфактум,
|
- `docs/adr/` — **неизменяемый** журнал решений, пишется постфактум,
|
||||||
хранит *почему*. Правила — [docs/adr/README.md](docs/adr/README.md).
|
хранит *почему*. Правила — [docs/adr/README.md](docs/adr/README.md).
|
||||||
- `docs/drafts/` — черновики: планы, идеи, ещё не принятые решения. Не
|
- `docs/drafts/` — черновики: планы, идеи, ещё не принятые решения. Не
|
||||||
@@ -71,6 +115,15 @@ Module path — `git.vakhrushev.me/av/jellybit`. Go 1.26, `CGO_ENABLED=0`.
|
|||||||
|
|
||||||
- Раскладка: `cmd/jellybit` (точка входа) + `internal/<пакет>` по
|
- Раскладка: `cmd/jellybit` (точка входа) + `internal/<пакет>` по
|
||||||
компонентам из [architecture.md](docs/specs/architecture.md).
|
компонентам из [architecture.md](docs/specs/architecture.md).
|
||||||
- Ошибки оборачиваем с контекстом (`fmt.Errorf("...: %w", err)`).
|
- Ошибки — stdlib, обёртка с контекстом (`fmt.Errorf("...: %w", err)`),
|
||||||
- Логирование только через `slog`, без `fmt.Println`.
|
проверка через `errors.Is`/`errors.As`, трансляция на внешней границе:
|
||||||
|
[docs/conventions/errors.md](docs/conventions/errors.md).
|
||||||
|
- Логирование только через `slog`, без `fmt.Println` — уровни, обязательные
|
||||||
|
поля и что не логировать см. [docs/conventions/logging.md](docs/conventions/logging.md).
|
||||||
|
- Конфигурация — только TOML; секреты рендерит деплой (Ansible+Vault) в
|
||||||
|
файл (`config.toml` не коммитится, `0600`), не в env; валидация на старте:
|
||||||
|
[docs/conventions/config.md](docs/conventions/config.md).
|
||||||
- Время — всегда с явным TZ (сервер в `Europe/Moscow`).
|
- Время — всегда с явным TZ (сервер в `Europe/Moscow`).
|
||||||
|
|
||||||
|
Кросс-каттинг конвенции (как пишем код, а не что система делает) живут в
|
||||||
|
[docs/conventions/](docs/conventions/README.md) и не переносятся в OpenSpec.
|
||||||
|
|||||||
+3
-2
@@ -13,8 +13,9 @@ COPY jellybit /usr/local/bin/jellybit
|
|||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из
|
# В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из
|
||||||
# /config/config.toml — дефолтный путь). compose может переопределить параметры.
|
# конфига). Путь задаём явно: дефолт загрузчика — config.toml в рабочей
|
||||||
|
# директории, а конфиг смонтирован в /config. compose может переопределить.
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD ["/usr/local/bin/jellybit", "healthcheck"]
|
CMD ["/usr/local/bin/jellybit", "healthcheck", "--config", "/config/config.toml"]
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/config/config.toml"]
|
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/config/config.toml"]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
// нет shell/curl: docker зовёт сам бинарь.
|
// нет shell/curl: docker зовёт сам бинарь.
|
||||||
func runHealthcheck(args []string) error {
|
func runHealthcheck(args []string) error {
|
||||||
fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError)
|
fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError)
|
||||||
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
|
configPath := fs.String("config", config.DefaultPath, "путь к config.toml")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,21 @@ func serveHealthz(t *testing.T, status int) int {
|
|||||||
|
|
||||||
func writeConfig(t *testing.T, port int) string {
|
func writeConfig(t *testing.T, port int) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(t.TempDir(), "config.toml")
|
dir := t.TempDir()
|
||||||
content := "[http]\nlisten = \"127.0.0.1:" + strconv.Itoa(port) + "\"\n"
|
// Медиа-пути должны существовать как каталоги (fail-fast валидация конфига).
|
||||||
|
for _, sub := range []string{"downloads", "movies", "series"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "config.toml")
|
||||||
|
content := "" +
|
||||||
|
"[qbittorrent]\nurl = \"http://qbit:8080\"\npassword = \"secret\"\n\n" +
|
||||||
|
"[paths]\n" +
|
||||||
|
"downloads = \"" + filepath.Join(dir, "downloads") + "\"\n" +
|
||||||
|
"movies = \"" + filepath.Join(dir, "movies") + "\"\n" +
|
||||||
|
"series = \"" + filepath.Join(dir, "series") + "\"\n\n" +
|
||||||
|
"[http]\nlisten = \"127.0.0.1:" + strconv.Itoa(port) + "\"\n"
|
||||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -11,6 +11,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -38,7 +40,15 @@ func main() {
|
|||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = os.Stderr.WriteString("fatal: " + err.Error() + "\n")
|
if cmd == "serve" {
|
||||||
|
// Фатальный сбой старта сервиса — структурный лог ERROR (как в проде),
|
||||||
|
// затем ненулевой код возврата.
|
||||||
|
logging.NewStderr().Error("fatal startup", "command", cmd, "error", err)
|
||||||
|
} else {
|
||||||
|
// Диагностические CLI (add/recognize/healthcheck) — человекочитаемый
|
||||||
|
// stderr, это пользовательский вывод, а не лог сервиса.
|
||||||
|
_, _ = os.Stderr.WriteString("fatal: " + err.Error() + "\n")
|
||||||
|
}
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
// Только чтение: ни записи в БД, ни хардлинков.
|
// Только чтение: ни записи в БД, ни хардлинков.
|
||||||
func runRecognize(args []string) error {
|
func runRecognize(args []string) error {
|
||||||
fs := flag.NewFlagSet("recognize", flag.ContinueOnError)
|
fs := flag.NewFlagSet("recognize", flag.ContinueOnError)
|
||||||
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
|
configPath := fs.String("config", config.DefaultPath, "путь к config.toml")
|
||||||
dryRun := fs.Bool("dry-run", true, "только показать план, без изменений (единственный режим)")
|
dryRun := fs.Bool("dry-run", true, "только показать план, без изменений (единственный режим)")
|
||||||
contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания")
|
contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import (
|
|||||||
// воркер (фоном) → HTTP-сервер; останавливается по SIGINT/SIGTERM.
|
// воркер (фоном) → HTTP-сервер; останавливается по SIGINT/SIGTERM.
|
||||||
func runServe(args []string) error {
|
func runServe(args []string) error {
|
||||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||||
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
|
configPath := fs.String("config", config.DefaultPath, "путь к config.toml")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -174,7 +174,7 @@ func runServe(args []string) error {
|
|||||||
}
|
}
|
||||||
api, terr := tgbotapi.NewBotAPIWithClient(cfg.Telegram.Token, tgbotapi.APIEndpoint, tgClient)
|
api, terr := tgbotapi.NewBotAPIWithClient(cfg.Telegram.Token, tgbotapi.APIEndpoint, tgClient)
|
||||||
if terr != nil {
|
if terr != nil {
|
||||||
logger.Error("telegram bot disabled: cannot connect", "err", terr)
|
logger.Error("telegram bot disabled, cannot connect", "error", terr)
|
||||||
} else {
|
} else {
|
||||||
bot := tgbot.New(api, ingestor, wrk, tgbot.Config{
|
bot := tgbot.New(api, ingestor, wrk, tgbot.Config{
|
||||||
AllowedUserIDs: cfg.Telegram.AllowedUserIDs,
|
AllowedUserIDs: cfg.Telegram.AllowedUserIDs,
|
||||||
|
|||||||
+52
-46
@@ -1,76 +1,82 @@
|
|||||||
# Пример конфигурации jellybit. Реальный config.toml не коммитится (содержит
|
# Пример конфигурации jellybit — единый справочник по всем секциям и полям.
|
||||||
# секреты). Для локального запуска: db_path -> ./jellybit.db.
|
# Реальный config.toml не коммитится (содержит секреты), заполняется деплоем.
|
||||||
|
# Секретные поля здесь оставлены пустыми. По умолчанию загрузчик ищет
|
||||||
|
# config.toml в рабочей директории; путь переопределяется опцией --config=path.
|
||||||
|
# Для локального запуска укажите существующие каталоги и db_path -> ./jellybit.db.
|
||||||
|
|
||||||
[qbittorrent]
|
[qbittorrent]
|
||||||
url = "http://qbit:8989" # по имени сервиса в общей docker-сети
|
url = "http://qbit:8989" # адрес qBittorrent WebUI; в docker-сети — по имени сервиса
|
||||||
username = "admin"
|
username = "admin" # логин WebUI
|
||||||
password = ""
|
password = "" # секрет: пароль WebUI; обязателен, заполняет деплой
|
||||||
category = "jellybit" # категория для добавляемых jellybit раздач (push)
|
category = "jellybit" # категория для добавляемых jellybit раздач (push, savepath)
|
||||||
tag = "jellybit" # тег для усыновления существующих раздач (pull, не двигает файлы)
|
tag = "jellybit" # тег для усыновления существующих раздач (pull, не двигает файлы)
|
||||||
savepath = "/srv/media/downloads" # qBit кладёт загрузки сюда (задаём при добавлении)
|
savepath = "/srv/media/downloads" # куда qBittorrent кладёт загрузки (задаём при добавлении)
|
||||||
path_map = {} # фолбэк: префикс save_path → хост-префикс, напр. {"/data" = "/srv/media"}; обычно пуст
|
path_map = {} # фолбэк: префикс save_path → хост-префикс, напр. {"/data" = "/srv/media"}; обычно пуст
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
downloads = "/srv/media/downloads"
|
# Медиа-песочница на хосте. Каждый путь: абсолютный, без traversal (`..`) и
|
||||||
movies = "/srv/media/movies"
|
# должен существовать как доступный каталог (проверяется на старте). Целевые
|
||||||
series = "/srv/media/series"
|
# movies/series и источник downloads монтируются под единый корень (/srv/media).
|
||||||
|
downloads = "/srv/media/downloads" # источник: где лежат загрузки qBittorrent (только читаем/линкуем)
|
||||||
|
movies = "/srv/media/movies" # целевой каталог фильмов для Jellyfin (раскладка хардлинками)
|
||||||
|
series = "/srv/media/series" # целевой каталог сериалов для Jellyfin (раскладка хардлинками)
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
db_path = "/data/jellybit.db" # SQLite на persistent-томе
|
db_path = "/data/jellybit.db" # путь к файлу SQLite на persistent-томе; обязателен
|
||||||
|
|
||||||
[llm]
|
[llm]
|
||||||
type = "openai-compat"
|
type = "openai-compat" # провайдер распознавания; допустимо: openai-compat
|
||||||
# LLM на хосте (LM Studio) из bridged-контейнера — через host.docker.internal.
|
# LLM на хосте (LM Studio) из bridged-контейнера — через host.docker.internal.
|
||||||
base_url = "http://host.docker.internal:1234/v1"
|
base_url = "http://host.docker.internal:1234/v1" # эндпоинт LLM; пусто = распознавание выключено
|
||||||
api_key = ""
|
api_key = "" # секрет: ключ LLM; обязателен, если задан base_url (заполняет деплой)
|
||||||
model = "qwen2.5-32b-instruct"
|
model = "qwen2.5-32b-instruct" # имя модели на эндпоинте
|
||||||
proxy = "" # опц. HTTP-прокси для удалённых эндпоинтов
|
proxy = "" # опц. HTTP-прокси для удалённых эндпоинтов; пусто = без прокси
|
||||||
timeout = "120s"
|
timeout = "120s" # таймаут запроса к LLM; Go-duration (s/m/h)
|
||||||
max_retries = 3
|
max_retries = 3 # попыток получить валидный ответ LLM; целое ≥ 0
|
||||||
|
|
||||||
[metadata.tmdb]
|
[metadata.tmdb]
|
||||||
enabled = false # включается ключом; без матча авто не делаем
|
enabled = false # включить провайдера TMDB; без матча авто-раскладку не делаем
|
||||||
api_key = ""
|
api_key = "" # секрет: ключ TMDB; обязателен, если enabled (заполняет деплой)
|
||||||
proxy = ""
|
proxy = "" # опц. HTTP-прокси; пусто = без прокси
|
||||||
timeout = "10s"
|
timeout = "10s" # таймаут запроса к TMDB; Go-duration (s/m/h)
|
||||||
|
|
||||||
[metadata.tvdb]
|
[metadata.tvdb]
|
||||||
enabled = false
|
enabled = false # включить провайдера TVDB
|
||||||
api_key = ""
|
api_key = "" # секрет: ключ TVDB; обязателен, если enabled (заполняет деплой)
|
||||||
proxy = ""
|
proxy = "" # опц. HTTP-прокси; пусто = без прокси
|
||||||
timeout = "10s"
|
timeout = "10s" # таймаут запроса к TVDB; Go-duration (s/m/h)
|
||||||
|
|
||||||
[metadata.tvmaze]
|
[metadata.tvmaze]
|
||||||
enabled = false # без ключа; только сериалы, тег [tvdbid-…] из externals
|
enabled = false # включить провайдера TVMaze; без ключа, только сериалы (тег [tvdbid-…] из externals)
|
||||||
proxy = ""
|
proxy = "" # опц. HTTP-прокси; пусто = без прокси
|
||||||
timeout = "10s"
|
timeout = "10s" # таймаут запроса к TVMaze; Go-duration (s/m/h)
|
||||||
|
|
||||||
[jellyfin]
|
[jellyfin]
|
||||||
enabled = false # включить пересканирование медиатеки после раскладки
|
enabled = false # включить пересканирование медиатеки после раскладки
|
||||||
url = "http://jellyfin:8096" # по имени сервиса в общей docker-сети
|
url = "http://jellyfin:8096" # адрес Jellyfin; обязателен, если enabled (в docker-сети — по имени сервиса)
|
||||||
api_key = "" # API-ключ Jellyfin (Dashboard → API Keys)
|
api_key = "" # секрет: API-ключ Jellyfin (Dashboard → API Keys); обязателен, если enabled
|
||||||
proxy = "" # опц. HTTP-прокси
|
proxy = "" # опц. HTTP-прокси; пусто = без прокси
|
||||||
timeout = "10s"
|
timeout = "10s" # таймаут запроса к Jellyfin; Go-duration (s/m/h)
|
||||||
|
|
||||||
[worker]
|
[worker]
|
||||||
poll_interval = "5s"
|
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
|
||||||
stuck_after = "1h"
|
stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration
|
||||||
magnet_timeout = "30m"
|
magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration
|
||||||
|
|
||||||
[recognition]
|
[recognition]
|
||||||
auto_confidence_threshold = 0.85
|
auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0
|
||||||
|
|
||||||
[telegram]
|
[telegram]
|
||||||
enabled = false
|
enabled = false # включить Telegram-бота
|
||||||
token = ""
|
token = "" # секрет: токен бота; обязателен, если enabled (заполняет деплой)
|
||||||
allowed_user_ids = [] # пусто = запрет всем (fail-closed)
|
allowed_user_ids = [] # allowlist Telegram user id (целые); пусто = запрет всем (fail-closed)
|
||||||
web_base_url = "" # напр. "http://jellybit:8080" — для кнопки «открыть в вебе»
|
web_base_url = "" # база для deep-link «открыть в вебе», напр. "http://jellybit:8080"; пусто = без кнопки
|
||||||
proxy = "" # опц. HTTP-прокси для api.telegram.org
|
proxy = "" # опц. HTTP-прокси для api.telegram.org; пусто = без прокси
|
||||||
|
|
||||||
[http]
|
[http]
|
||||||
listen = ":8080"
|
listen = ":8080" # адрес прослушивания HTTP-сервера; формат [host]:port
|
||||||
trusted_subnets = [] # ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN); зарезервировано
|
trusted_subnets = [] # allowlist подсетей (CIDR); ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN), зарезервировано
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
level = "info"
|
level = "info" # уровень логирования; одно из: debug, info, warn, error
|
||||||
format = "json"
|
format = "json" # формат логов; одно из: json, text
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Конвенции кода
|
||||||
|
|
||||||
|
Кросс-каттинг правила того, **как** мы пишем код (логирование, ошибки,
|
||||||
|
именование) — в отличие от `docs/specs/` и `openspec/specs/`, которые
|
||||||
|
описывают, **что** система делает.
|
||||||
|
|
||||||
|
Конвенции **не** переносятся в OpenSpec: это не capability. Короткие
|
||||||
|
инварианты дублируются в [CLAUDE.md](../../CLAUDE.md) (агент читает его
|
||||||
|
всегда) и кратко в `openspec/config.yaml` → `context` (подмешивается в
|
||||||
|
генерацию артефактов); детали — здесь. Обоснование «почему» — в `docs/adr/`.
|
||||||
|
|
||||||
|
## Записи
|
||||||
|
|
||||||
|
- [logging.md](logging.md) — логирование: уровни, поля, что не логируем.
|
||||||
|
- [config.md](config.md) — конфигурация: TOML, секреты через деплой
|
||||||
|
(Ansible+Vault), валидация на старте.
|
||||||
|
- [errors.md](errors.md) — ошибки: stdlib, обёртка `%w`, `errors.Is`/`As`,
|
||||||
|
трансляция на внешней границе.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Конфигурация
|
||||||
|
|
||||||
|
Конвенция: *как* устроена и грузится конфигурация jellybit (TOML).
|
||||||
|
Правила оформления кода (How), не спецификация поведения.
|
||||||
|
|
||||||
|
Краткая выжимка и инварианты — в [CLAUDE.md](../../CLAUDE.md), раздел
|
||||||
|
«Конвенции кода».
|
||||||
|
|
||||||
|
> Каркас. Загрузчик `internal/config/config.go` уже грузит TOML; валидация
|
||||||
|
> на старте — в работе (`TODO`), обкатывается на следующем шаге.
|
||||||
|
|
||||||
|
## Принципы
|
||||||
|
|
||||||
|
- **Конфигурация — только TOML.** Env-переменные для конфига **не
|
||||||
|
используем**: окружение наследуется дочерними процессами и видно через
|
||||||
|
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
|
||||||
|
- Грузим **один раз при старте** в одну типизированную структуру `Config`
|
||||||
|
(под-структуры по секциям). Дальше по коду читаем только её — никаких
|
||||||
|
`os.Getenv`/чтения файла в бизнес-коде, только загрузчик `internal/config`.
|
||||||
|
- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса.
|
||||||
|
|
||||||
|
## Файл и поиск
|
||||||
|
|
||||||
|
- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочей
|
||||||
|
директории** процесса.
|
||||||
|
- Путь переопределяется опцией **`--config=path`**.
|
||||||
|
- Образец в репозитории — **`config.example.toml`** (см. ниже); реальный
|
||||||
|
`config.toml` не коммитится.
|
||||||
|
|
||||||
|
## config.example.toml — самодокументируемый образец
|
||||||
|
|
||||||
|
`config.example.toml` коммитим как единый справочник по конфигу: все секции
|
||||||
|
и все поля. **Каждое поле снабжаем комментарием**, из которого ясно:
|
||||||
|
|
||||||
|
- **зачем** поле — что оно меняет в поведении;
|
||||||
|
- **диапазон/допустимые значения** — перечисление или границы;
|
||||||
|
- **единицы измерения**, если применимо — секунды/миллисекунды, байты/КБ,
|
||||||
|
доля `0–1` и т.п.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[worker]
|
||||||
|
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
|
||||||
|
magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration
|
||||||
|
|
||||||
|
[recognition]
|
||||||
|
auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0
|
||||||
|
|
||||||
|
[llm]
|
||||||
|
max_retries = 3 # попыток получить валидный ответ LLM; целое ≥ 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Секретные поля оставляем пустыми — значение приходит из деплоя (см.
|
||||||
|
«Секреты»).
|
||||||
|
|
||||||
|
## Поля по дискриминатору `type`
|
||||||
|
|
||||||
|
Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного
|
||||||
|
из бекендов/внешних сервисов — напр. `[llm].type`), обязательность и
|
||||||
|
опциональность полей определяются значением `type`, а не фиксированы для
|
||||||
|
секции.
|
||||||
|
|
||||||
|
- **Валидация — по `type`.** Для каждого поддерживаемого `type` свой набор
|
||||||
|
обязательных полей; поля, относящиеся к другим `type`, не требуются.
|
||||||
|
Неизвестный `type` → ошибка на старте с перечислением поддерживаемых.
|
||||||
|
- **Образец — по `type`.** В `config.example.toml`:
|
||||||
|
- основной (дефолтный) `type` **предзаполнен** рабочими значениями;
|
||||||
|
- альтернативные `type` — **блоками-комментариями ниже**, каждый со своим
|
||||||
|
описанием полей (зачем/диапазон/единицы — как у обычных полей);
|
||||||
|
- так из примера видны все варианты и поля каждого, не открывая код.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[llm]
|
||||||
|
type = "openai-compat" # бекенд LLM; варианты ниже
|
||||||
|
base_url = "http://host.docker.internal:1234/v1" # эндпоинт OpenAI-совместимого API
|
||||||
|
api_key = "" # ключ; пусто для keyless-local (LM Studio)
|
||||||
|
model = "qwen2.5-32b-instruct" # имя модели у провайдера
|
||||||
|
|
||||||
|
# --- альтернативный бекенд: type = "<other>" ---
|
||||||
|
# [llm]
|
||||||
|
# type = "<other>" # описание варианта
|
||||||
|
# ... # его обязательные/опциональные поля
|
||||||
|
```
|
||||||
|
|
||||||
|
## Секреты
|
||||||
|
|
||||||
|
Секреты доставляет **деплой**, рендеря их прямо в `config.toml` (jellybit:
|
||||||
|
Ansible + Vault). Приложение просто читает TOML — отдельного слоя секретов
|
||||||
|
в коде нет. Источник истины секрета — внешнее хранилище деплоя (Vault), не
|
||||||
|
репозиторий и не env.
|
||||||
|
|
||||||
|
- Секретные поля jellybit: `qbittorrent.password`, `llm.api_key`,
|
||||||
|
`metadata.*.api_key`, `jellyfin.api_key`, `telegram.token`.
|
||||||
|
- Рендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
||||||
|
владелец — runtime-пользователь (`1000:1000`).
|
||||||
|
- В `config.example.toml` секретные поля — пустые строки.
|
||||||
|
- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит
|
||||||
|
криво отрендеренный файл) — см. «Валидация и fail-fast».
|
||||||
|
- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность».
|
||||||
|
|
||||||
|
## Валидация и fail-fast
|
||||||
|
|
||||||
|
Конфиг валидируем **на старте, до приёма трафика**. Невалидный конфиг —
|
||||||
|
лог `ERROR` и выход с ненулевым кодом (не стартуем «наполовину»).
|
||||||
|
|
||||||
|
Что проверяем (jellybit):
|
||||||
|
|
||||||
|
- обязательные поля заданы (напр. `qbittorrent.url`, `paths.*`,
|
||||||
|
`storage.db_path`);
|
||||||
|
- пути `paths.movies`/`series`/`downloads` существуют и доступны; целевые —
|
||||||
|
под единой песочницей (см. инварианты в [CLAUDE.md](../../CLAUDE.md));
|
||||||
|
- диапазоны: `recognition.auto_confidence_threshold` ∈ [0, 1],
|
||||||
|
`llm.max_retries` ≥ 0;
|
||||||
|
- длительности парсятся (`llm.timeout`, `worker.poll_interval`, …);
|
||||||
|
- включённые секции консистентны: `metadata.tmdb.enabled` → задан `api_key`;
|
||||||
|
`jellyfin.enabled` → заданы `url`+`api_key`; `telegram.enabled` → `token`.
|
||||||
|
|
||||||
|
## Структура в коде
|
||||||
|
|
||||||
|
- Весь разбор и валидация — в `internal/config`; наружу отдаётся готовая
|
||||||
|
`Config`.
|
||||||
|
- Одна корневая структура `Config` с под-структурами по секциям
|
||||||
|
(`QBittorrent`, `Paths`, `LLM`, `Metadata`, `Jellyfin`, `Worker`,
|
||||||
|
`Recognition`, `Telegram`, `HTTP`, `Log`).
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Ошибки
|
||||||
|
|
||||||
|
Конвенция: *как* устроены и передаются ошибки в jellybit. Правила оформления
|
||||||
|
кода (How). Где и когда ошибку **логировать** — в [logging.md](logging.md),
|
||||||
|
раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как
|
||||||
|
ошибки строятся, оборачиваются и проверяются.
|
||||||
|
|
||||||
|
## Базовая идиома: stdlib
|
||||||
|
|
||||||
|
- Только стандартный `errors` + `fmt.Errorf`. Без `pkg/errors` (в режиме
|
||||||
|
поддержки) и `cockroachdb/errors` (стек-трейсы/Sentry — избыточно для
|
||||||
|
домашнего сервиса). Контекст ошибки несёт `slog`, а не стек.
|
||||||
|
- Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал
|
||||||
|
пересмотреть, а не дефолт.
|
||||||
|
|
||||||
|
## Обёртка и контекст
|
||||||
|
|
||||||
|
jellybit — **приложение, а не библиотека**: внешнего Go-API нет, весь код
|
||||||
|
наш. Поэтому внутри приложения обёртка `%w` — **дефолт**, чтобы `errors.Is`/
|
||||||
|
`errors.As` работали сквозь слои.
|
||||||
|
|
||||||
|
- Добавляем контекст обёрткой: `fmt.Errorf("parse magnet: %w", err)`.
|
||||||
|
- `%w` — когда вызывающий может инспектировать причину (наш обычный случай).
|
||||||
|
`%v` — когда причину сознательно **не** раскрываем (не хотим завязывать
|
||||||
|
вызывающего на чужой тип ошибки).
|
||||||
|
- От утечки внутренних ошибок наружу защищаемся **не** через `%v` в цепочке,
|
||||||
|
а трансляцией на внешней границе (см. ниже).
|
||||||
|
|
||||||
|
Стиль сообщения:
|
||||||
|
|
||||||
|
- со строчной, без точки в конце, без «failed to»/«error» — обёртка и так
|
||||||
|
читается как «контекст: причина»;
|
||||||
|
- контекст — операция/субъект: `"link target: %w"`, не `"something failed"`;
|
||||||
|
- без заикания: каждый слой добавляет **свой** смысл, не повторяет нижний
|
||||||
|
(`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`).
|
||||||
|
|
||||||
|
## Проверка ошибок
|
||||||
|
|
||||||
|
- Сравнение — только `errors.Is(err, ErrX)` (не `err == ErrX`) и
|
||||||
|
`errors.As(err, &target)`. **Никогда** не матчим по тексту
|
||||||
|
(`strings.Contains(err.Error(), …)`).
|
||||||
|
- Граничные ошибки зависимостей **транслируем в доменные у источника**:
|
||||||
|
`sql.ErrNoRows` → доменный `store.ErrNotFound` в слое store, чтобы выше по
|
||||||
|
коду не торчал `database/sql`.
|
||||||
|
|
||||||
|
## Sentinel vs типизированные
|
||||||
|
|
||||||
|
- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий,
|
||||||
|
на которые ветвится код (нет записи, дубликат по infohash,
|
||||||
|
неподдерживаемый источник). Проверяем `errors.Is`.
|
||||||
|
- **Типизированная ошибка** (тип с полями + метод `Error()`) — когда
|
||||||
|
вызывающему нужны **данные** ошибки (поле валидации, код). Достаём
|
||||||
|
`errors.As`. Не плодим типы там, где хватает sentinel.
|
||||||
|
|
||||||
|
## Граница и трансляция: приватный vs публичный канал
|
||||||
|
|
||||||
|
Внутри — богатые обёрнутые ошибки. На внешней границе ошибку **транслируем**,
|
||||||
|
и форма зависит от канала, кто его видит:
|
||||||
|
|
||||||
|
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей
|
||||||
|
цепочкой `%w` и контекстом. Пишется один раз на доменной границе — см.
|
||||||
|
[logging.md](logging.md).
|
||||||
|
- **Публичный канал — пользовательские поверхности** (Telegram, web-UI, HTTP
|
||||||
|
API; ими пользуется не только владелец). Сюда отдаём:
|
||||||
|
- **человекочитаемое сообщение** по доменной ошибке — не сырой
|
||||||
|
`err.Error()` и не детали реализации (`database/sql`, пути, стек);
|
||||||
|
- **+ корреляционный ключ** для владельца — `download_id` (если операция
|
||||||
|
к загрузке) либо `request_id`, чтобы по нему найти полную ошибку в логах.
|
||||||
|
Пример: «При обработке загрузки произошла ошибка, download_id=12345», а
|
||||||
|
не «произошла ошибка» и не сырой текст;
|
||||||
|
- **маппинг доменной ошибки → статус/сообщение**: `ErrNotFound` → 404
|
||||||
|
«не найдено», валидация/`ErrNotMagnet` → 400 «некорректный источник»,
|
||||||
|
конфликт состояния (`ErrConflict` — операция недопустима в текущем
|
||||||
|
состоянии) → 409 «действие недоступно в текущем состоянии», прочее →
|
||||||
|
500 «внутренняя ошибка».
|
||||||
|
|
||||||
|
Граница публичная по умолчанию. Истинно приватный для владельца канал —
|
||||||
|
логи; отдельной «операторской» поверхности с сырыми ошибками не заводим.
|
||||||
|
|
||||||
|
## panic
|
||||||
|
|
||||||
|
- `panic` — только для невосстановимого: баг программиста (нарушенный
|
||||||
|
инвариант), ошибка инициализации, из которой нельзя стартовать.
|
||||||
|
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой
|
||||||
|
ввод) — это значения `error`.
|
||||||
|
- `recover` — на верхней границе обработчика (HTTP middleware), чтобы один
|
||||||
|
паникующий запрос не ронял процесс.
|
||||||
|
|
||||||
|
## Несколько ошибок
|
||||||
|
|
||||||
|
- Сбор независимых ошибок (напр. валидация конфига — все проблемы разом) —
|
||||||
|
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# Логирование
|
||||||
|
|
||||||
|
Конвенция: *как* и *когда* писать логи в jellybit. Это правила оформления
|
||||||
|
кода (How), а не спецификация поведения — наблюдаемые требования к логам
|
||||||
|
(что система ОБЯЗАНА залогировать как часть контракта capability) живут в
|
||||||
|
OpenSpec-спеках (`### Requirement` с `SHALL`).
|
||||||
|
|
||||||
|
Краткая выжимка и инварианты — в [CLAUDE.md](../../CLAUDE.md), раздел
|
||||||
|
«Конвенции кода».
|
||||||
|
|
||||||
|
## Принципы
|
||||||
|
|
||||||
|
- Только `log/slog`, без `fmt.Println` и прямой записи в stdout.
|
||||||
|
- Структурированный JSON (`slog.JSONHandler`), один формат для dev и prod.
|
||||||
|
- Сообщение (`msg`) — константный шаблон/категория события; данные — в
|
||||||
|
полях (атрибутах `slog`), а не в интерполяции текста.
|
||||||
|
- Каждое поле — отдельный ключ с типизированным значением. Это даёт
|
||||||
|
фильтрацию и агрегацию через `jq`/DuckDB без регулярок.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"time":"2026-06-28T11:23:45.123456Z","level":"INFO","msg":"download accepted","capability":"ingest","download_id":"a1b2","infohash":"…","media_type":"movie","title":"Дюна: Часть вторая"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Сообщение
|
||||||
|
|
||||||
|
- `msg` — короткая константа в нижнем регистре: `download accepted`,
|
||||||
|
`recognition done`, `layout failed`. Без переменных в тексте.
|
||||||
|
- Данные кладём в атрибуты: `slog.Info("download accepted", "download_id",
|
||||||
|
id, "infohash", ih)`.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Правильно: msg — категория, данные — поля
|
||||||
|
log.Info("download accepted", "download_id", id, "media_type", "movie")
|
||||||
|
|
||||||
|
// Неправильно: данные зашиты в текст, агрегация ломается
|
||||||
|
log.Info(fmt.Sprintf("download %s accepted as movie", id))
|
||||||
|
```
|
||||||
|
|
||||||
|
- `msg` — чистая категория без неймспейс-префикса: `recognition done`, а не
|
||||||
|
`recognize: done`. Подсистему выносим в поле `capability`
|
||||||
|
(`ingest`/`recognition`/`file-layout`/`review`), не в текст.
|
||||||
|
|
||||||
|
## Уровни
|
||||||
|
|
||||||
|
Принцип: уровень — это **адресат** («кому сообщение»), а не «насколько
|
||||||
|
громко сломалось». `slog` даёт четыре уровня; их и используем.
|
||||||
|
|
||||||
|
| Уровень | Кому и когда | Примеры в jellybit |
|
||||||
|
|---|---|---|
|
||||||
|
| `DEBUG` | разработчику при отладке; в проде выключен | healthcheck-эндпоинты, поллинг статуса в qBittorrent, авто-рефреш UI, тела запросов/ответов внешних API, промежуточные шаги распознавания |
|
||||||
|
| `INFO` | команде, аудит постфактум | приём загрузки, распознан фильм/сериал, раскладка выполнена, старт процессов, **событийный вызов внешнего сервиса** (по реальному действию) |
|
||||||
|
| `WARN` | команде, «может стать проблемой» | retry внешнего вызова, низкая уверенность распознавания (ушло в ревью), приближение к лимиту |
|
||||||
|
| `ERROR` | команде, в техдолг / разбор | внешний сервис недоступен после ретраев, операция загрузки не выполнена, необработанная ошибка |
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- Уровень **не зависит от capability** — `ERROR` в `ingest` и в
|
||||||
|
`file-layout` одинаково серьёзны.
|
||||||
|
- `WARN` ≠ «ничего страшного». `WARN` = «может стать проблемой». Если это
|
||||||
|
не «может» — это `INFO`.
|
||||||
|
- Меняется адресат — меняется уровень. Невалидный ввод от пользователя —
|
||||||
|
это `DEBUG` (норма, команде разбирать нечего), а не `ERROR`.
|
||||||
|
- **Событийное → INFO, рутинно-частое → DEBUG.** Операция, срабатывающая по
|
||||||
|
реальному действию/изменению (приём загрузки, добавление торрента, вызов
|
||||||
|
LLM, раскладка), идёт на `INFO`. Повторяющаяся служебная операция,
|
||||||
|
которую запускает таймер/поллинг и которая сама по себе не несёт события
|
||||||
|
(healthcheck, поллинг статуса в qBittorrent, авто-рефреш UI), — на
|
||||||
|
`DEBUG`: на `INFO` она зашумляет аудит. Такие записи смотрят редко, при
|
||||||
|
предметной отладке (DEBUG включают точечно).
|
||||||
|
- `slog` не разделяет CRITICAL/FATAL — фатальный сбой на старте логируем
|
||||||
|
`ERROR` и завершаем процесс (ненулевой код возврата).
|
||||||
|
|
||||||
|
## Время
|
||||||
|
|
||||||
|
- Поле — `time` (ключ по умолчанию `slog`).
|
||||||
|
- UTC, RFC 3339 с долями секунды, суффикс `Z`:
|
||||||
|
`2026-06-28T11:23:45.123456Z`.
|
||||||
|
- Логи — **в UTC** (это явный TZ, не нарушает инвариант проекта): даёт
|
||||||
|
однозначный порядок событий и лексикографическую сортировку. Бизнес-логика
|
||||||
|
по-прежнему работает в `Europe/Moscow` — UTC только в логах.
|
||||||
|
|
||||||
|
## Поля: словарь имён
|
||||||
|
|
||||||
|
Главное условие — **единый словарь**: одно поле — одно имя по всему коду
|
||||||
|
(не `mediaType`/`media`/`media_type` вперемешку).
|
||||||
|
|
||||||
|
- Бизнес-/доменные поля — плоский `snake_case`.
|
||||||
|
- Системные домены — точечная иерархия (адаптация OpenTelemetry): `http.*`,
|
||||||
|
`ext.*`.
|
||||||
|
- JSON плоский: все поля на верхнем уровне, без вложенности.
|
||||||
|
|
||||||
|
| Когда добавляем | Поля |
|
||||||
|
|---|---|
|
||||||
|
| на входящий HTTP-запрос (middleware) | `transport` (`http`/`web`/`telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
|
||||||
|
| на загрузку (scoped-логгер, см. ниже) | `capability` (`ingest`/`recognition`/`file-layout`/`review`), `download_id`, `infohash`, `media_type`, `title` |
|
||||||
|
| на запись об ошибке | `error` |
|
||||||
|
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
||||||
|
|
||||||
|
Не заводим `service.*`/`host.*` — для одного бинаря на одном хосте это шум.
|
||||||
|
Если когда-нибудь поедем в несколько инстансов, добавим `service.version`
|
||||||
|
одной строкой при старте.
|
||||||
|
|
||||||
|
## Корреляция по download_id
|
||||||
|
|
||||||
|
Отдельный случайный `trace_id` не заводим — у загрузки уже есть стабильный
|
||||||
|
осмысленный ключ: `download_id` (и `infohash`), он лежит в SQLite.
|
||||||
|
|
||||||
|
- Заводим scoped-логгер на загрузку и протаскиваем его через
|
||||||
|
`context.Context` сквозь асинхронные стадии (приём → скачивание →
|
||||||
|
распознавание → раскладка), чтобы ключ дописывался на каждую запись сам:
|
||||||
|
|
||||||
|
```go
|
||||||
|
log := log.With("download_id", id, "infohash", ih)
|
||||||
|
ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии
|
||||||
|
```
|
||||||
|
|
||||||
|
- Все записи одной загрузки собираются одним фильтром:
|
||||||
|
`jq 'select(.download_id=="a1b2")' app.jsonl`.
|
||||||
|
|
||||||
|
## Ошибки
|
||||||
|
|
||||||
|
Go-ошибки логируем как атрибут, не как текст сообщения.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Правильно: msg — категория, ошибка — поле
|
||||||
|
log.Error("layout failed", "error", err, "download_id", id)
|
||||||
|
|
||||||
|
// Неправильно: ошибка зашита в msg, агрегация по событию ломается
|
||||||
|
log.Error(err.Error())
|
||||||
|
```
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- Ошибку передаём полем `"error", err` — не склеиваем в `msg`. Ключ —
|
||||||
|
`error` (как по умолчанию в zap/zerolog; единый ключ важнее краткости).
|
||||||
|
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
|
||||||
|
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя —
|
||||||
|
контекст накапливается в цепочке `%w`.
|
||||||
|
- Логируем ошибку **один раз — на границе доменного слоя** (use-case
|
||||||
|
`Ingest`, стадии воркера), которая определяет исход операции: полем
|
||||||
|
`error`, уровень `ERROR`. В Go логирует этот единый чокпоинт, а не каждый
|
||||||
|
транспорт — так транспорты остаются тонкими.
|
||||||
|
- Транспорты (HTTP/web/Telegram) переводят возвращённую ошибку в свой ответ
|
||||||
|
(статус, сообщение пользователю) и **не логируют** её повторно — иначе
|
||||||
|
один сбой даёт дубли.
|
||||||
|
- Телеметрия внешнего вызова (`ext.*`, см. ниже) — отдельная запись о
|
||||||
|
поведении зависимости, не дубль доменной ошибки.
|
||||||
|
- Глушить ошибку без лога — только с однострочным комментарием «почему».
|
||||||
|
|
||||||
|
## Внешние сервисы (обязательно логируем все вызовы)
|
||||||
|
|
||||||
|
**Каждый** вызов внешнего сервиса (qBittorrent, Jellyfin, LLM, TMDB/TVDB)
|
||||||
|
логируется. Поля:
|
||||||
|
|
||||||
|
- `ext.service` — `qbittorrent` / `jellyfin` / `llm` / `tmdb` / `tvdb`;
|
||||||
|
- `ext.operation` — логическая операция (`torrents/add`, `chat.completions`,
|
||||||
|
`search/movie`);
|
||||||
|
- `ext.status_code` — HTTP-код ответа (если применимо);
|
||||||
|
- `duration_ms` — длительность вызова;
|
||||||
|
- `retry` — номер попытки (если были ретраи).
|
||||||
|
|
||||||
|
Уровни вызова:
|
||||||
|
|
||||||
|
- `INFO` — успешный **событийный** вызов (по реальному действию: добавление
|
||||||
|
торрента, вызов LLM, рефреш Jellyfin, поиск в метабазе);
|
||||||
|
- `DEBUG` — успешный **рутинно-частый** вызов (поллинг статуса
|
||||||
|
`torrents/info`/`torrents/files`, авто-рефреш) — см. правило «событийное →
|
||||||
|
INFO, рутинно-частое → DEBUG» в разделе «Уровни»;
|
||||||
|
- `WARN` — попытка не удалась, делаем retry;
|
||||||
|
- `ERROR` — ретраи исчерпаны / сервис недоступен (сетевой сбой/таймаут).
|
||||||
|
Завершённый HTTP-ответ с 4xx — это успех на транспортном уровне (`Success`
|
||||||
|
с `ext.status_code`); решение «это ошибка» принимает доменный вызывающий.
|
||||||
|
|
||||||
|
Тело запроса/ответа — только на `DEBUG` и **после** вычистки секретов
|
||||||
|
(см. «Безопасность»).
|
||||||
|
|
||||||
|
## HTTP и healthcheck
|
||||||
|
|
||||||
|
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
|
||||||
|
`http.status_code`, `duration_ms`, `transport` (`http`/`web`/`telegram`).
|
||||||
|
- Для корреляции HTTP-запроса допустим `request_id` (напр. chi `RequestID`) —
|
||||||
|
это отдельный слой от корреляции загрузки по `download_id` и не противоречит
|
||||||
|
отказу от `trace_id`. Если запрос порождает загрузку — связь даёт
|
||||||
|
`download_id` в её записях.
|
||||||
|
- **Эндпоинты healthcheck/liveness/readiness логируем на `DEBUG`** — их
|
||||||
|
дёргают периодически, на `INFO` они забивают аудит шумом. В проде
|
||||||
|
(базовый уровень `INFO`) они не пишутся.
|
||||||
|
|
||||||
|
## Безопасность: что не логируем
|
||||||
|
|
||||||
|
Никаких секретов в полях и сообщениях. Под запретом:
|
||||||
|
|
||||||
|
- учётные данные qBittorrent (логин/пароль, cookie сессии);
|
||||||
|
- API-ключ и токен LLM-провайдера, `Authorization`-заголовки;
|
||||||
|
- ключи TMDB/TVDB и прочих метабаз;
|
||||||
|
- содержимое аутентификационных параметров magnet/трекеров.
|
||||||
|
|
||||||
|
Дополнительно:
|
||||||
|
|
||||||
|
- Тела запросов/ответов внешних API и сырой вывод LLM (недоверенный, может
|
||||||
|
быть большим) — только на `DEBUG`, с вычисткой секретов и обрезкой по длине.
|
||||||
|
- При сомнении — не логируем значение, логируем факт его наличия
|
||||||
|
(`"has_api_key", true`).
|
||||||
|
|
||||||
|
## Куда пишем и уровень
|
||||||
|
|
||||||
|
- Пишем JSON в `stdout` одним потоком; сбор и ротацию делает окружение
|
||||||
|
(docker/journald). Не маршрутизируем по файлам.
|
||||||
|
- Базовый уровень в проде — `INFO`; `DEBUG` включается через конфиг/env при
|
||||||
|
необходимости. dev — `DEBUG`.
|
||||||
|
|
||||||
|
## Анализ
|
||||||
|
|
||||||
|
- Повседневно — `jq` (`jq 'select(.download_id=="a1b2")' app.jsonl`).
|
||||||
|
- Тяжёлое (агрегации, JOIN) — DuckDB поверх JSONL прямо из файла.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Конвенции кода: бэклог
|
||||||
|
|
||||||
|
Кандидаты в [docs/conventions/](../conventions/README.md), ещё не принятые.
|
||||||
|
Пишем по мере реального трения, а не вперёд; принятое переезжает в
|
||||||
|
`docs/conventions/`. Уже приняты: `logging.md`, `config.md`, `errors.md`.
|
||||||
|
|
||||||
|
## Tier 2 — кандидаты в отдельный доку
|
||||||
|
|
||||||
|
Завести, когда паттерн подтвердит второй-третий проект (или поймаем трение
|
||||||
|
в jellybit).
|
||||||
|
|
||||||
|
### Раскладка пакетов и направление зависимостей
|
||||||
|
|
||||||
|
`cmd/<bin>` (точка входа) + `internal/<компонент>` по доменам. Домен не
|
||||||
|
импортирует транспорт; зависимости направлены внутрь, к домену. Без свалок
|
||||||
|
`util`/`common`/`helpers`. Стыкуется с «тонкие транспорты, единое ядро» из
|
||||||
|
архитектуры.
|
||||||
|
|
||||||
|
### context.Context
|
||||||
|
|
||||||
|
Первый параметр функции; не хранить в структурах; в `Value` только
|
||||||
|
request-scoped данные, не зависимости. Перенос логгера/корреляции через ctx
|
||||||
|
(уже реализовано — `internal/logctx`, см. `logging.md`). Дедлайны/отмена
|
||||||
|
протягиваются сквозь стадии.
|
||||||
|
|
||||||
|
### Внешние клиенты (HTTP к зависимостям)
|
||||||
|
|
||||||
|
Таймаут на **каждый** исходящий вызов (не полагаться на дефолт); не
|
||||||
|
`http.DefaultClient`; ретраи с backoff и потолком попыток; HTTP-прокси из
|
||||||
|
конфига (`proxy`-поля уже есть). Прямое продолжение `ext.*`-логирования
|
||||||
|
(`logging.md`) и трансляции ошибок (`errors.md`). Кандидат — общий
|
||||||
|
конструктор клиента вместо копипасты в qbt/llm/jellyfin/metadata.
|
||||||
|
|
||||||
|
### Тесты
|
||||||
|
|
||||||
|
Table-driven; фикстуры в `testdata/`; `t.Parallel()` где безопасно; выбрать
|
||||||
|
и зафиксировать stdlib `testing` vs `testify`; разделение быстрых и
|
||||||
|
интеграционных (уже есть `*_integration_test.go` + env-гейты). Что считаем
|
||||||
|
обязательным к покрытию (валидация конфига, распознавание, раскладка).
|
||||||
|
|
||||||
|
## Tier 3 — тонкий бюллетень или мелочь
|
||||||
|
|
||||||
|
Не тянет на отдельный доку: строка-инвариант в `CLAUDE.md` или стек-специфика.
|
||||||
|
|
||||||
|
### БД и миграции (SQLite + goose)
|
||||||
|
|
||||||
|
Миграции forward-only; запросы только параметризованные (без склейки строк);
|
||||||
|
явные транзакции для многошаговых изменений; context-aware запросы. Сильно
|
||||||
|
стек-специфично — возможно, в `CLAUDE.md`, не в общий доку.
|
||||||
|
|
||||||
|
### Конкурентность
|
||||||
|
|
||||||
|
Каждая горутина знает, **как** останавливается (ctx/закрытие канала); без
|
||||||
|
утечек; `errgroup` для связанных задач; фоновые процессы гасятся при
|
||||||
|
shutdown. Актуально для воркера/фоновых задач, не для всего проекта.
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
Данные — в `stdout`, логи и диагностика — в `stderr`; осмысленные коды
|
||||||
|
возврата. Для CLI-подмножества проектов (у jellybit — диагностические
|
||||||
|
команды `add`/`recognize`/`healthcheck`).
|
||||||
|
|
||||||
|
### Время
|
||||||
|
|
||||||
|
Явный TZ всегда; хранение и логи — в UTC; бизнес-логика в `Europe/Moscow`.
|
||||||
|
Уже частично в `CLAUDE.md` и `logging.md` — при желании свести в один
|
||||||
|
короткий инвариант.
|
||||||
@@ -5,11 +5,16 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultPath — имя конфига по умолчанию: ищется в рабочей директории
|
||||||
|
// процесса. Переопределяется опцией --config=path.
|
||||||
|
const DefaultPath = "config.toml"
|
||||||
|
|
||||||
// Config — корневая конфигурация сервиса (см. config.example.toml).
|
// Config — корневая конфигурация сервиса (см. config.example.toml).
|
||||||
type Config struct {
|
type Config struct {
|
||||||
QBittorrent QBittorrent `toml:"qbittorrent"`
|
QBittorrent QBittorrent `toml:"qbittorrent"`
|
||||||
@@ -195,15 +200,98 @@ func Load(path string) (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validate — fail-fast проверка конфига на старте: обязательные поля заданы,
|
||||||
|
// медиа-пути доступны и не выходят из песочницы, диапазоны соблюдены, секреты
|
||||||
|
// включённых секций не пусты. Длительности уже провалидированы при разборе
|
||||||
|
// TOML (UnmarshalText). Лог об ошибке пишет граница (cmd/jellybit), не загрузчик.
|
||||||
func (c *Config) validate() error {
|
func (c *Config) validate() error {
|
||||||
|
// Собираем все проблемы разом (errors.Join), чтобы оператор увидел все
|
||||||
|
// огрехи отрендеренного файла за один проход, а не правил их по одной.
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
// Обязательные поля ядра.
|
||||||
|
if c.QBittorrent.URL == "" {
|
||||||
|
errs = append(errs, errors.New("qbittorrent.url is empty"))
|
||||||
|
}
|
||||||
if c.HTTP.Listen == "" {
|
if c.HTTP.Listen == "" {
|
||||||
return errors.New("http.listen is empty")
|
errs = append(errs, errors.New("http.listen is empty"))
|
||||||
}
|
}
|
||||||
if c.Storage.DBPath == "" {
|
if c.Storage.DBPath == "" {
|
||||||
return errors.New("storage.db_path is empty")
|
errs = append(errs, errors.New("storage.db_path is empty"))
|
||||||
}
|
}
|
||||||
if c.LLM.Type != "openai-compat" {
|
if c.LLM.Type != "openai-compat" {
|
||||||
return fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type)
|
errs = append(errs, fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Медиа-пути песочницы: абсолютные, без traversal, существующие каталоги.
|
||||||
|
for _, p := range []struct{ name, path string }{
|
||||||
|
{"paths.downloads", c.Paths.Downloads},
|
||||||
|
{"paths.movies", c.Paths.Movies},
|
||||||
|
{"paths.series", c.Paths.Series},
|
||||||
|
} {
|
||||||
|
if err := validateMediaDir(p.name, p.path); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диапазоны.
|
||||||
|
if t := c.Recognition.AutoConfidenceThreshold; t < 0 || t > 1 {
|
||||||
|
errs = append(errs, fmt.Errorf("recognition.auto_confidence_threshold %.3f is out of range [0, 1]", t))
|
||||||
|
}
|
||||||
|
if c.LLM.MaxRetries < 0 {
|
||||||
|
errs = append(errs, fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обязательные секреты включённых секций (ловит криво отрендеренный деплоем
|
||||||
|
// файл). qBittorrent — ядро, пароль нужен всегда.
|
||||||
|
if c.QBittorrent.Password == "" {
|
||||||
|
errs = append(errs, errors.New("qbittorrent.password is empty (required secret)"))
|
||||||
|
}
|
||||||
|
// llm.api_key намеренно не обязателен: keyless-local LLM (LM Studio с
|
||||||
|
// заданным base_url, но без ключа) — валидный документированный дефолт.
|
||||||
|
|
||||||
|
// Консистентность опциональных секций: enabled ⇒ заданы нужные поля/секреты.
|
||||||
|
if c.Metadata.TMDB.Enabled && c.Metadata.TMDB.APIKey == "" {
|
||||||
|
errs = append(errs, errors.New("metadata.tmdb.enabled but metadata.tmdb.api_key is empty"))
|
||||||
|
}
|
||||||
|
if c.Metadata.TVDB.Enabled && c.Metadata.TVDB.APIKey == "" {
|
||||||
|
errs = append(errs, errors.New("metadata.tvdb.enabled but metadata.tvdb.api_key is empty"))
|
||||||
|
}
|
||||||
|
if c.Jellyfin.Enabled {
|
||||||
|
if c.Jellyfin.URL == "" {
|
||||||
|
errs = append(errs, errors.New("jellyfin.enabled but jellyfin.url is empty"))
|
||||||
|
}
|
||||||
|
if c.Jellyfin.APIKey == "" {
|
||||||
|
errs = append(errs, errors.New("jellyfin.enabled but jellyfin.api_key is empty (required secret)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Telegram.Enabled && c.Telegram.Token == "" {
|
||||||
|
errs = append(errs, errors.New("telegram.enabled but telegram.token is empty (required secret)"))
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateMediaDir проверяет путь медиа-песочницы: непустой, абсолютный, без
|
||||||
|
// traversal (filepath.Clean — без `..`/лишних разделителей) и указывает на
|
||||||
|
// существующий доступный каталог. Отдельного корня песочницы в конфиге нет,
|
||||||
|
// поэтому «строго под песочницей» обеспечиваем абсолютностью и отсутствием
|
||||||
|
// traversal; единый монтируемый корень (/srv/media) — забота деплоя.
|
||||||
|
func validateMediaDir(name, path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("%s is empty", name)
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return fmt.Errorf("%s %q must be an absolute path", name, path)
|
||||||
|
}
|
||||||
|
if filepath.Clean(path) != path {
|
||||||
|
return fmt.Errorf("%s %q must be a clean path (no .. or redundant separators)", name, path)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s %q is not accessible: %w", name, path, err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("%s %q is not a directory", name, path)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validCfg возвращает минимально валидный конфиг поверх Default() с медиа-путями
|
||||||
|
// во временном каталоге (существуют как директории).
|
||||||
|
func validCfg(t *testing.T) *Config {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
c := Default()
|
||||||
|
c.QBittorrent.Password = "secret"
|
||||||
|
c.Paths.Downloads = filepath.Join(dir, "downloads")
|
||||||
|
c.Paths.Movies = filepath.Join(dir, "movies")
|
||||||
|
c.Paths.Series = filepath.Join(dir, "series")
|
||||||
|
for _, p := range []string{c.Paths.Downloads, c.Paths.Movies, c.Paths.Series} {
|
||||||
|
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// LLM по умолчанию без base_url — секция выключена, api_key не требуется.
|
||||||
|
c.LLM.BaseURL = ""
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_OK(t *testing.T) {
|
||||||
|
if err := validCfg(t).validate(); err != nil {
|
||||||
|
t.Fatalf("ожидался валидный конфиг, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidate_KeylessLocalLLM — keyless-local LLM (задан base_url, пустой
|
||||||
|
// api_key, напр. LM Studio) — валиден: ключ не обязателен.
|
||||||
|
func TestValidate_KeylessLocalLLM(t *testing.T) {
|
||||||
|
c := validCfg(t)
|
||||||
|
c.LLM.BaseURL = "http://host.docker.internal:1234/v1"
|
||||||
|
c.LLM.APIKey = ""
|
||||||
|
if err := c.validate(); err != nil {
|
||||||
|
t.Fatalf("keyless-local LLM должен быть валиден, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_Errors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*Config)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"empty qbittorrent.url", func(c *Config) { c.QBittorrent.URL = "" }, "qbittorrent.url"},
|
||||||
|
{"empty qbittorrent.password", func(c *Config) { c.QBittorrent.Password = "" }, "qbittorrent.password"},
|
||||||
|
{"empty db_path", func(c *Config) { c.Storage.DBPath = "" }, "storage.db_path"},
|
||||||
|
{"bad llm.type", func(c *Config) { c.LLM.Type = "anthropic" }, "llm.type"},
|
||||||
|
{"relative movies", func(c *Config) { c.Paths.Movies = "movies" }, "absolute"},
|
||||||
|
{"traversal series", func(c *Config) { c.Paths.Series = c.Paths.Series + "/../x" }, "clean"},
|
||||||
|
{"missing downloads", func(c *Config) { c.Paths.Downloads = "/no/such/dir/jellybit" }, "not accessible"},
|
||||||
|
{"threshold high", func(c *Config) { c.Recognition.AutoConfidenceThreshold = 1.5 }, "auto_confidence_threshold"},
|
||||||
|
{"negative retries", func(c *Config) { c.LLM.MaxRetries = -1 }, "max_retries"},
|
||||||
|
{"tmdb enabled no key", func(c *Config) { c.Metadata.TMDB.Enabled = true }, "metadata.tmdb"},
|
||||||
|
{"tvdb enabled no key", func(c *Config) { c.Metadata.TVDB.Enabled = true }, "metadata.tvdb"},
|
||||||
|
{"jellyfin enabled no url", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "" }, "jellyfin.url"},
|
||||||
|
{"jellyfin enabled no key", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "http://j"; c.Jellyfin.APIKey = "" }, "jellyfin.api_key"},
|
||||||
|
{"telegram enabled no token", func(c *Config) { c.Telegram.Enabled = true }, "telegram.token"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c := validCfg(t)
|
||||||
|
tc.mutate(c)
|
||||||
|
err := c.validate()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatalf("ожидалась ошибка про %q, got %v", tc.want, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
-18
@@ -8,6 +8,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -20,7 +21,9 @@ import (
|
|||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
"git.vakhrushev.me/av/jellybit/web"
|
"git.vakhrushev.me/av/jellybit/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -135,7 +138,7 @@ type downloadView struct {
|
|||||||
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
downloads, err := s.deps.Reader.ListDownloads(r.Context())
|
downloads, err := s.deps.Reader.ListDownloads(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.deps.Logger.Error("list downloads", "err", err)
|
s.deps.Logger.Error("list downloads", "error", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -145,7 +148,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := s.index.Execute(w, view); err != nil {
|
if err := s.index.Execute(w, view); err != nil {
|
||||||
s.deps.Logger.Error("render index", "err", err)
|
s.deps.Logger.Error("render index", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,12 +157,12 @@ func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
redirectErr(w, r, "не удалось разобрать форму")
|
redirectErr(w, r, "не удалось разобрать форму")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{
|
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{
|
||||||
Source: r.PostForm.Get("source"),
|
Source: r.PostForm.Get("source"),
|
||||||
Context: r.PostForm.Get("context"),
|
Context: r.PostForm.Get("context"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
redirectErr(w, r, err.Error())
|
redirectErr(w, r, userErr(r, err, res.DownloadID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
@@ -172,7 +175,7 @@ func (s *server) handleUICancel(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.deps.Commander.Cancel(r.Context(), id); err != nil {
|
if err := s.deps.Commander.Cancel(r.Context(), id); err != nil {
|
||||||
redirectErr(w, r, err.Error())
|
redirectErr(w, r, userErr(r, err, id))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
@@ -207,7 +210,7 @@ type addResponse struct {
|
|||||||
func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
|
||||||
downloads, err := s.deps.Reader.ListDownloads(r.Context())
|
downloads, err := s.deps.Reader.ListDownloads(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, errJSON(err))
|
s.apiErr(w, r, err, 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]downloadDTO, 0, len(downloads))
|
out := make([]downloadDTO, 0, len(downloads))
|
||||||
@@ -220,12 +223,13 @@ func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := pathID(r)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, errJSON(err))
|
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusNotFound, errJSON(err))
|
// ErrNotFound → 404, реальный сбой БД → 500 (не маскируем под 404).
|
||||||
|
s.apiErr(w, r, err, id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, toDTO(*d))
|
writeJSON(w, http.StatusOK, toDTO(*d))
|
||||||
@@ -234,12 +238,15 @@ func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
var req addRequest
|
var req addRequest
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, errJSON(err))
|
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный запрос", 0))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
|
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, errJSON(err))
|
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit) —
|
||||||
|
// тогда коррелируем по download_id, иначе (ранний разбор источника) по
|
||||||
|
// request_id.
|
||||||
|
s.apiErr(w, r, err, res.DownloadID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
status := http.StatusCreated
|
status := http.StatusCreated
|
||||||
@@ -265,12 +272,14 @@ func (s *server) handleAPIRetry(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, int64) error) {
|
func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, int64) error) {
|
||||||
id, err := pathID(r)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, errJSON(err))
|
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := cmd(r.Context(), id); err != nil {
|
if err := cmd(r.Context(), id); err != nil {
|
||||||
s.deps.Logger.Warn("api command failed", "path", r.URL.Path, "id", id, "err", err)
|
// Тонкий транспорт: возвращённую use-case'ом/воркером ошибку переводим в
|
||||||
writeJSON(w, http.StatusConflict, errJSON(err))
|
// статус+сообщение и не логируем повторно (доменный слой уже залогировал,
|
||||||
|
// а невалидный ввод — норма, разбирать команде нечего).
|
||||||
|
s.apiErr(w, r, err, id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||||
@@ -337,8 +346,54 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
|||||||
_ = json.NewEncoder(w).Encode(v)
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
func errJSON(err error) map[string]string {
|
// classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное
|
||||||
return map[string]string{"error": err.Error()}
|
// человекочитаемое сообщение публичного канала (без сырого err.Error() и
|
||||||
|
// деталей реализации): ErrNotFound → 404, валидация источника
|
||||||
|
// (magnet.ErrNotMagnet) → 400, конфликт состояния (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):
|
||||||
|
return http.StatusBadRequest, "некорректный источник"
|
||||||
|
case errors.Is(err, worker.ErrConflict):
|
||||||
|
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
||||||
|
return http.StatusConflict, "действие недоступно в текущем состоянии"
|
||||||
|
default:
|
||||||
|
return http.StatusInternalServerError, "внутренняя ошибка"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errBody — тело ошибки REST API: нейтральное сообщение + корреляционный ключ
|
||||||
|
// для владельца (download_id, если операция привязана к загрузке, иначе
|
||||||
|
// request_id запроса), по которому он найдёт полную ошибку в логах.
|
||||||
|
func errBody(r *http.Request, msg string, downloadID int64) map[string]any {
|
||||||
|
body := map[string]any{"error": msg}
|
||||||
|
if downloadID > 0 {
|
||||||
|
body["download_id"] = downloadID
|
||||||
|
} else {
|
||||||
|
body["request_id"] = middleware.GetReqID(r.Context())
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiErr пишет ответ об ошибке REST API по доменной ошибке (статус + тело).
|
||||||
|
func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downloadID int64) {
|
||||||
|
status, msg := classifyErr(err)
|
||||||
|
writeJSON(w, status, errBody(r, msg, downloadID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// userErr — сообщение публичного канала для веб-UI: нейтральный текст по
|
||||||
|
// доменной ошибке + корреляционный ключ владельцу (download_id, если операция
|
||||||
|
// привязана к загрузке, иначе request_id). Сырой текст ошибки наружу не идёт.
|
||||||
|
func userErr(r *http.Request, err error, downloadID int64) string {
|
||||||
|
_, msg := classifyErr(err)
|
||||||
|
if downloadID > 0 {
|
||||||
|
return fmt.Sprintf("%s (download_id=%d)", msg, downloadID)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestLogger пишет структурированный лог по каждому запросу. Частые
|
// requestLogger пишет структурированный лог по каждому запросу. Частые
|
||||||
@@ -352,10 +407,17 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
|||||||
|
|
||||||
next.ServeHTTP(ww, r)
|
next.ServeHTTP(ww, r)
|
||||||
|
|
||||||
|
// http.route — низкокардинальный шаблон маршрута (chi), а не
|
||||||
|
// конкретный путь; при отсутствии шаблона падаем на путь.
|
||||||
|
route := chi.RouteContext(r.Context()).RoutePattern()
|
||||||
|
if route == "" {
|
||||||
|
route = r.URL.Path
|
||||||
|
}
|
||||||
logger.Log(r.Context(), requestLogLevel(r), "http request",
|
logger.Log(r.Context(), requestLogLevel(r), "http request",
|
||||||
"method", r.Method,
|
"transport", "http",
|
||||||
"path", r.URL.Path,
|
"http.method", r.Method,
|
||||||
"status", ww.Status(),
|
"http.route", route,
|
||||||
|
"http.status_code", ww.Status(),
|
||||||
"bytes", ww.BytesWritten(),
|
"bytes", ww.BytesWritten(),
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
"request_id", middleware.GetReqID(r.Context()),
|
"request_id", middleware.GetReqID(r.Context()),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/httpapi"
|
"git.vakhrushev.me/av/jellybit/internal/httpapi"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
@@ -104,7 +106,9 @@ func TestAPIAdd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIAddBadInput(t *testing.T) {
|
func TestAPIAddBadInput(t *testing.T) {
|
||||||
ing := &fakeIngestor{err: ingestErr("bad magnet")}
|
// Источник не magnet → ingest оборачивает magnet.ErrNotMagnet; транспорт
|
||||||
|
// классифицирует это как 400 (некорректный источник).
|
||||||
|
ing := &fakeIngestor{err: fmt.Errorf("ingest: parse source: %w", magnet.ErrNotMagnet)}
|
||||||
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||||
|
|
||||||
resp, err := http.Post(srv.URL+"/api/downloads", "application/json", strings.NewReader(`{"source":"x"}`))
|
resp, err := http.Post(srv.URL+"/api/downloads", "application/json", strings.NewReader(`{"source":"x"}`))
|
||||||
@@ -156,6 +160,26 @@ func TestAPICancel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPICommandConflict(t *testing.T) {
|
||||||
|
// Конфликт состояния (worker.ErrConflict) → 409, не 500.
|
||||||
|
cmd := &fakeCommander{err: fmt.Errorf("cancel: download 5 in wrong state: %w", worker.ErrConflict)}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/api/downloads/5/cancel", "", nil)
|
||||||
|
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 got["download_id"].(float64) != 5 {
|
||||||
|
t.Errorf("download_id корреляции нет: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIndexRenders(t *testing.T) {
|
func TestIndexRenders(t *testing.T) {
|
||||||
reader := &fakeReader{list: []store.Download{
|
reader := &fakeReader{list: []store.Download{
|
||||||
{ID: 1, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
|
{ID: 1, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
|
||||||
|
|||||||
+14
-16
@@ -2,12 +2,12 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,12 +78,12 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
http.Error(w, "задача не найдена", http.StatusNotFound)
|
http.Error(w, "задача не найдена", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.deps.Logger.Error("review data", "id", id, "err", err)
|
s.deps.Logger.Error("review data", "id", id, "error", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := s.review.Execute(w, view); err != nil {
|
if err := s.review.Execute(w, view); err != nil {
|
||||||
s.deps.Logger.Error("render review", "err", err)
|
s.deps.Logger.Error("render review", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,8 +153,9 @@ func (s *server) handleApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.deps.Reviewer.Apply(r.Context(), id); err != nil {
|
if err := s.deps.Reviewer.Apply(r.Context(), id); err != nil {
|
||||||
s.deps.Logger.Warn("review action failed", "action", "apply", "id", id, "err", err)
|
// Тонкий транспорт: ошибку воркера переводим в ответ, не логируя
|
||||||
redirectReview(w, r, id, err.Error())
|
// повторно (доменный слой уже залогировал реальный сбой).
|
||||||
|
redirectReview(w, r, id, userErr(r, err, id))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
@@ -220,8 +221,7 @@ func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.deps.Reviewer.Defer(r.Context(), id); err != nil {
|
if err := s.deps.Reviewer.Defer(r.Context(), id); err != nil {
|
||||||
s.deps.Logger.Warn("review action failed", "action", "defer", "id", id, "err", err)
|
redirectReview(w, r, id, userErr(r, err, id))
|
||||||
redirectReview(w, r, id, err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
@@ -234,8 +234,7 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil {
|
if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil {
|
||||||
s.deps.Logger.Warn("review action failed", "action", "undo", "id", id, "err", err)
|
redirectErr(w, r, userErr(r, err, id))
|
||||||
redirectErr(w, r, err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
@@ -250,8 +249,7 @@ func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil {
|
if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil {
|
||||||
s.deps.Logger.Warn("review action failed", "action", "relink", "id", id, "err", err)
|
redirectErr(w, r, userErr(r, err, id))
|
||||||
redirectErr(w, r, err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
@@ -266,9 +264,9 @@ func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(co
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := fn(r.Context(), id); err != nil {
|
if err := fn(r.Context(), id); err != nil {
|
||||||
s.deps.Logger.Warn("review action failed",
|
// Тонкий транспорт: ошибку переводим в ?err= на странице ревью, не
|
||||||
"action", r.URL.Path, "id", id, "err", err)
|
// логируя повторно (доменный слой/валидация — не дело транспорта).
|
||||||
redirectReview(w, r, id, err.Error())
|
redirectReview(w, r, id, userErr(r, err, id))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
redirectReview(w, r, id, "")
|
redirectReview(w, r, id, "")
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// capIngest — стадия приёма для поля capability в логах.
|
||||||
|
const capIngest = "ingest"
|
||||||
|
|
||||||
// Store — нужная ingest часть хранилища.
|
// Store — нужная ingest часть хранилища.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
|
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
|
||||||
@@ -74,14 +78,19 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
|||||||
info, err := magnet.Parse(source)
|
info, err := magnet.Parse(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Ф1: поддержан только magnet. .torrent/url — следующий заход.
|
// Ф1: поддержан только magnet. .torrent/url — следующий заход.
|
||||||
return Result{}, fmt.Errorf("ingest: %w", err)
|
return Result{}, fmt.Errorf("ingest: parse source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scoped-логгер стадии приёма: download_id допишется после CreateDownload.
|
||||||
|
// Кладём в ctx, чтобы внешние клиенты (qBittorrent, LLM-namer) дописывали
|
||||||
|
// ключи корреляции к своим ext.*-записям сами.
|
||||||
|
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
|
||||||
|
ctx = logctx.With(ctx, log)
|
||||||
|
|
||||||
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohash); err != nil {
|
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohash); err != nil {
|
||||||
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
||||||
} else if existing != nil {
|
} else if existing != nil {
|
||||||
s.log.Info("ingest: attached to active download",
|
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||||||
"download_id", existing.ID, "infohash", info.Infohash, "state", existing.State)
|
|
||||||
return Result{
|
return Result{
|
||||||
DownloadID: existing.ID,
|
DownloadID: existing.ID,
|
||||||
Infohash: info.Infohash,
|
Infohash: info.Infohash,
|
||||||
@@ -111,6 +120,8 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("ingest: create download: %w", err)
|
return Result{}, fmt.Errorf("ingest: create download: %w", err)
|
||||||
}
|
}
|
||||||
|
log = log.With("download_id", id)
|
||||||
|
ctx = logctx.With(ctx, log)
|
||||||
|
|
||||||
addErr := s.qbt.Add(ctx, qbt.AddRequest{
|
addErr := s.qbt.Add(ctx, qbt.AddRequest{
|
||||||
URLs: []string{source},
|
URLs: []string{source},
|
||||||
@@ -119,19 +130,19 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
|||||||
Rename: rename,
|
Rename: rename,
|
||||||
})
|
})
|
||||||
if addErr != nil {
|
if addErr != nil {
|
||||||
s.log.Warn("ingest: qbittorrent add failed, marking download failed",
|
// Граница доменной операции приёма: логируем исход один раз (ERROR).
|
||||||
"download_id", id, "infohash", info.Infohash, "err", addErr)
|
// Поведение самого вызова qBittorrent уже залогировал клиент (ext.*) —
|
||||||
|
// это разные факты, не дубль.
|
||||||
|
log.Error("download accept failed", "error", addErr)
|
||||||
// Задача уже в БД — помечаем failed, чтобы worker её не подхватил.
|
// Задача уже в БД — помечаем failed, чтобы worker её не подхватил.
|
||||||
if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, "qbit_add", addErr.Error()); setErr != nil {
|
if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, "qbit_add", addErr.Error()); setErr != nil {
|
||||||
s.log.Error("ingest: failed to mark download failed after qbit error",
|
log.Error("mark download failed after qbit error failed", "error", setErr)
|
||||||
"download_id", id, "err", setErr)
|
|
||||||
}
|
}
|
||||||
return Result{DownloadID: id, Infohash: info.Infohash, State: store.StateFailed},
|
return Result{DownloadID: id, Infohash: info.Infohash, State: store.StateFailed},
|
||||||
fmt.Errorf("ingest: add to qbittorrent: %w", addErr)
|
fmt.Errorf("ingest: add to qbittorrent: %w", addErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Info("ingest: download accepted",
|
log.Info("download accepted", "category", s.cfg.Category)
|
||||||
"download_id", id, "infohash", info.Infohash, "category", s.cfg.Category)
|
|
||||||
return Result{
|
return Result{
|
||||||
DownloadID: id,
|
DownloadID: id,
|
||||||
Infohash: info.Infohash,
|
Infohash: info.Infohash,
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultTimeout = 10 * time.Second
|
const defaultTimeout = 10 * time.Second
|
||||||
@@ -77,17 +80,22 @@ func (c *Client) RefreshLibraries(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
req.Header.Set("X-Emby-Token", c.apiKey)
|
req.Header.Set("X-Emby-Token", c.apiKey)
|
||||||
|
|
||||||
start := time.Now()
|
log := logctx.FromOr(ctx, c.log)
|
||||||
|
call := logging.ExtCall{Service: logging.ServiceJellyfin, Operation: "library/refresh", Start: time.Now()}
|
||||||
resp, err := c.hc.Do(req)
|
resp, err := c.hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
call.Failure(log, err)
|
||||||
return fmt.Errorf("jellyfin: refresh: %w", err)
|
return fmt.Errorf("jellyfin: refresh: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
call.Status = resp.StatusCode
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
return fmt.Errorf("jellyfin: refresh: status %d body %q",
|
err := fmt.Errorf("jellyfin: refresh: status %d body %q",
|
||||||
resp.StatusCode, strings.TrimSpace(string(body)))
|
resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
call.Failure(log, err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
c.log.Info("jellyfin: library refresh triggered", "duration", time.Since(start))
|
call.Success(log)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -21,6 +21,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MediaType — вид контента.
|
// MediaType — вид контента.
|
||||||
@@ -230,7 +232,8 @@ var ErrCollision = errors.New("layout: target collision")
|
|||||||
// доводит начатое. При коллизии (цель занята чужим файлом) возвращает
|
// доводит начатое. При коллизии (цель занята чужим файлом) возвращает
|
||||||
// ErrCollision, не перезаписывая. Если хардлинк невозможен (разные ФС или ФС
|
// ErrCollision, не перезаписывая. Если хардлинк невозможен (разные ФС или ФС
|
||||||
// не поддерживает link) — фолбэк на копирование файла с предупреждением в лог.
|
// не поддерживает link) — фолбэк на копирование файла с предупреждением в лог.
|
||||||
func (l *Layouter) Apply(_ context.Context, links []Link) ([]Result, error) {
|
func (l *Layouter) Apply(ctx context.Context, links []Link) ([]Result, error) {
|
||||||
|
log := logctx.FromOr(ctx, l.log)
|
||||||
results := make([]Result, 0, len(links))
|
results := make([]Result, 0, len(links))
|
||||||
for _, ln := range links {
|
for _, ln := range links {
|
||||||
root := l.movies
|
root := l.movies
|
||||||
@@ -244,13 +247,13 @@ func (l *Layouter) Apply(_ context.Context, links []Link) ([]Result, error) {
|
|||||||
return results, fmt.Errorf("layout: mkdir %q: %w", filepath.Dir(ln.Dst), err)
|
return results, fmt.Errorf("layout: mkdir %q: %w", filepath.Dir(ln.Dst), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
status, err := l.linkOne(ln.Src, ln.Dst)
|
status, err := l.linkOne(log, ln.Src, ln.Dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.log.Error("layout: link failed",
|
log.Error("layout link failed",
|
||||||
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "err", err)
|
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "error", err)
|
||||||
return results, err
|
return results, err
|
||||||
}
|
}
|
||||||
l.log.Debug("layout: link applied",
|
log.Debug("layout link applied",
|
||||||
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "status", status)
|
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "status", status)
|
||||||
results = append(results, Result{Link: ln, Status: status})
|
results = append(results, Result{Link: ln, Status: status})
|
||||||
}
|
}
|
||||||
@@ -259,7 +262,7 @@ func (l *Layouter) Apply(_ context.Context, links []Link) ([]Result, error) {
|
|||||||
|
|
||||||
// linkOne создаёт одну ссылку, разбирая «уже существует» и невозможность
|
// linkOne создаёт одну ссылку, разбирая «уже существует» и невозможность
|
||||||
// хардлинка (фолбэк на копирование).
|
// хардлинка (фолбэк на копирование).
|
||||||
func (l *Layouter) linkOne(src, dst string) (LinkStatus, error) {
|
func (l *Layouter) linkOne(log *slog.Logger, src, dst string) (LinkStatus, error) {
|
||||||
err := os.Link(src, dst)
|
err := os.Link(src, dst)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return StatusLinked, nil
|
return StatusLinked, nil
|
||||||
@@ -279,8 +282,8 @@ func (l *Layouter) linkOne(src, dst string) (LinkStatus, error) {
|
|||||||
// раскладку — копируем файл и предупреждаем: диск дублируется, но
|
// раскладку — копируем файл и предупреждаем: диск дублируется, но
|
||||||
// задача доходит до конца. dst здесь заведомо отсутствует (иначе был бы
|
// задача доходит до конца. dst здесь заведомо отсутствует (иначе был бы
|
||||||
// fs.ErrExist выше).
|
// fs.ErrExist выше).
|
||||||
l.log.Warn("layout: hardlink unsupported, falling back to file copy",
|
log.Warn("layout hardlink unsupported, file copy fallback",
|
||||||
"src", src, "dst", dst, "err", err)
|
"src", src, "dst", dst, "error", err)
|
||||||
if cerr := copyFile(src, dst); cerr != nil {
|
if cerr := copyFile(src, dst); cerr != nil {
|
||||||
return "", fmt.Errorf("layout: copy fallback %q → %q: %w", src, dst, cerr)
|
return "", fmt.Errorf("layout: copy fallback %q → %q: %w", src, dst, cerr)
|
||||||
}
|
}
|
||||||
@@ -364,7 +367,8 @@ func sameFile(src, dst string) (bool, error) {
|
|||||||
// Undo удаляет ссылки и подчищает опустевшие каталоги. Снимает только пути
|
// Undo удаляет ссылки и подчищает опустевшие каталоги. Снимает только пути
|
||||||
// строго под библиотеками (источник недосягаем). Отсутствующая цель — не
|
// строго под библиотеками (источник недосягаем). Отсутствующая цель — не
|
||||||
// ошибка (идемпотентно). Возвращает число удалённых ссылок.
|
// ошибка (идемпотентно). Возвращает число удалённых ссылок.
|
||||||
func (l *Layouter) Undo(_ context.Context, links []Link) (int, error) {
|
func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) {
|
||||||
|
log := logctx.FromOr(ctx, l.log)
|
||||||
removed := 0
|
removed := 0
|
||||||
for _, ln := range links {
|
for _, ln := range links {
|
||||||
root := l.movies
|
root := l.movies
|
||||||
@@ -378,11 +382,11 @@ func (l *Layouter) Undo(_ context.Context, links []Link) (int, error) {
|
|||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
l.log.Error("layout: undo remove failed", "dst", ln.Dst, "err", err)
|
log.Error("layout undo remove failed", "dst", ln.Dst, "error", err)
|
||||||
return removed, fmt.Errorf("layout: undo remove %q: %w", ln.Dst, err)
|
return removed, fmt.Errorf("layout: undo remove %q: %w", ln.Dst, err)
|
||||||
}
|
}
|
||||||
removed++
|
removed++
|
||||||
l.log.Debug("layout: link removed", "dst", ln.Dst)
|
log.Debug("layout link removed", "dst", ln.Dst)
|
||||||
pruneEmptyDirs(filepath.Dir(ln.Dst), root)
|
pruneEmptyDirs(filepath.Dir(ln.Dst), root)
|
||||||
}
|
}
|
||||||
return removed, nil
|
return removed, nil
|
||||||
|
|||||||
+20
-17
@@ -11,6 +11,9 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -117,7 +120,7 @@ func (c *openAICompat) Complete(ctx context.Context, req Request) (Response, err
|
|||||||
return Response{}, fmt.Errorf("llm: marshal request: %w", err)
|
return Response{}, fmt.Errorf("llm: marshal request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
log := logctx.FromOr(ctx, c.log)
|
||||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
if attempt > 1 {
|
if attempt > 1 {
|
||||||
if err := c.wait(ctx, attempt); err != nil {
|
if err := c.wait(ctx, attempt); err != nil {
|
||||||
@@ -125,31 +128,31 @@ func (c *openAICompat) Complete(ctx context.Context, req Request) (Response, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.log.Debug("llm: request",
|
call := logging.ExtCall{
|
||||||
"endpoint", c.endpoint, "model", c.model,
|
Service: logging.ServiceLLM,
|
||||||
"attempt", attempt, "max_attempts", maxAttempts)
|
Operation: "chat.completions",
|
||||||
start := time.Now()
|
Start: time.Now(),
|
||||||
|
Attempt: attempt,
|
||||||
|
}
|
||||||
resp, retryable, err := c.do(ctx, body)
|
resp, retryable, err := c.do(ctx, body)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
c.log.Debug("llm: response ok",
|
call.Success(log, "model", resp.Model,
|
||||||
"model", resp.Model, "attempt", attempt,
|
|
||||||
"duration", time.Since(start),
|
|
||||||
"total_tokens", resp.Usage.TotalTokens, "cost", resp.Usage.Cost)
|
"total_tokens", resp.Usage.TotalTokens, "cost", resp.Usage.Cost)
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
lastErr = err
|
|
||||||
if !retryable {
|
if !retryable {
|
||||||
c.log.Error("llm: request failed (non-retryable)",
|
call.Failure(log, err, "model", c.model)
|
||||||
"model", c.model, "attempt", attempt, "duration", time.Since(start), "err", err)
|
|
||||||
return Response{}, err
|
return Response{}, err
|
||||||
}
|
}
|
||||||
c.log.Warn("llm: request failed, will retry",
|
if attempt < maxAttempts {
|
||||||
"model", c.model, "attempt", attempt, "max_attempts", maxAttempts,
|
call.Retry(log, err, "model", c.model)
|
||||||
"duration", time.Since(start), "err", err)
|
continue
|
||||||
|
}
|
||||||
|
// Последняя попытка тоже неуспешна — ретраи исчерпаны.
|
||||||
|
call.Failure(log, err, "model", c.model)
|
||||||
|
return Response{}, fmt.Errorf("llm: exhausted %d attempts: %w", maxAttempts, err)
|
||||||
}
|
}
|
||||||
c.log.Error("llm: all attempts exhausted",
|
return Response{}, fmt.Errorf("llm: exhausted %d attempts", maxAttempts)
|
||||||
"model", c.model, "max_attempts", maxAttempts, "err", lastErr)
|
|
||||||
return Response{}, fmt.Errorf("llm: exhausted %d attempts: %w", maxAttempts, lastErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *openAICompat) buildRequest(req Request) chatRequest {
|
func (c *openAICompat) buildRequest(req Request) chatRequest {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Package logctx переносит scoped-логдер загрузки через context.Context.
|
||||||
|
//
|
||||||
|
// На приёме/реконсиляции загрузки заводим логгер с её ключами
|
||||||
|
// (download_id [+ infohash], capability) и кладём в ctx; стадии (скачивание →
|
||||||
|
// распознавание → раскладка → ревью) достают его через From и пишут с уже
|
||||||
|
// дописанными ключами — без ручного доклеивания download_id в каждый вызов.
|
||||||
|
package logctx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ctxKey struct{}
|
||||||
|
|
||||||
|
// With возвращает ctx со вложенным логгером.
|
||||||
|
func With(ctx context.Context, log *slog.Logger) context.Context {
|
||||||
|
return context.WithValue(ctx, ctxKey{}, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// From достаёт логгер из ctx; при отсутствии — slog.Default().
|
||||||
|
func From(ctx context.Context) *slog.Logger {
|
||||||
|
return FromOr(ctx, slog.Default())
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromOr достаёт логгер из ctx; при отсутствии — fallback (или slog.Default(),
|
||||||
|
// если fallback nil). Удобно во внешних клиентах: внутри стадии загрузки вернёт
|
||||||
|
// scoped-логгер с download_id, при автономном вызове — собственный логгер клиента.
|
||||||
|
func FromOr(ctx context.Context, fallback *slog.Logger) *slog.Logger {
|
||||||
|
if ctx != nil {
|
||||||
|
if log, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok && log != nil {
|
||||||
|
return log
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fallback != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return slog.Default()
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Имена внешних сервисов для поля ext.service.
|
||||||
|
const (
|
||||||
|
ServiceQBittorrent = "qbittorrent"
|
||||||
|
ServiceJellyfin = "jellyfin"
|
||||||
|
ServiceLLM = "llm"
|
||||||
|
ServiceTMDB = "tmdb"
|
||||||
|
ServiceTVDB = "tvdb"
|
||||||
|
ServiceTVMaze = "tvmaze"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtCall описывает один вызов внешнего сервиса для логирования по конвенции
|
||||||
|
// (поля ext.*). Логгер передаётся аргументом — обычно scoped-логгер загрузки,
|
||||||
|
// чтобы запись о вызове несла download_id/capability стадии.
|
||||||
|
type ExtCall struct {
|
||||||
|
Service string // ext.service: qbittorrent/jellyfin/llm/tmdb/tvdb/tvmaze
|
||||||
|
Operation string // ext.operation: логическая операция (torrents/add, chat.completions, search/movie)
|
||||||
|
Start time.Time // начало вызова → duration_ms
|
||||||
|
Status int // ext.status_code: HTTP-код ответа; 0 — не писать (нет кода)
|
||||||
|
Attempt int // номер попытки; >0 — пишем поле retry (поле и метод Retry конфликтовали бы)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ExtCall) attrs(extra ...any) []any {
|
||||||
|
a := make([]any, 0, 10+len(extra))
|
||||||
|
a = append(a,
|
||||||
|
"ext.service", c.Service,
|
||||||
|
"ext.operation", c.Operation,
|
||||||
|
"duration_ms", time.Since(c.Start).Milliseconds(),
|
||||||
|
)
|
||||||
|
if c.Status > 0 {
|
||||||
|
a = append(a, "ext.status_code", c.Status)
|
||||||
|
}
|
||||||
|
if c.Attempt > 0 {
|
||||||
|
a = append(a, "retry", c.Attempt)
|
||||||
|
}
|
||||||
|
return append(a, extra...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success логирует успешный вызов внешнего сервиса (INFO) — одна запись на вызов.
|
||||||
|
func (c ExtCall) Success(log *slog.Logger, extra ...any) {
|
||||||
|
log.Info("external call", c.attrs(extra...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuccessDebug — успешный вызов на DEBUG: для частых служебных вызовов
|
||||||
|
// (поллинг qBittorrent и т.п.), которые на INFO забивали бы аудит шумом, как и
|
||||||
|
// healthcheck. Сам факт вызова логируется, но в проде (INFO) не пишется.
|
||||||
|
func (c ExtCall) SuccessDebug(log *slog.Logger, extra ...any) {
|
||||||
|
log.Debug("external call", c.attrs(extra...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry логирует неудачную попытку, после которой будет повтор (WARN).
|
||||||
|
func (c ExtCall) Retry(log *slog.Logger, err error, extra ...any) {
|
||||||
|
log.Warn("external call retry", c.attrs(append([]any{"error", err}, extra...)...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure логирует окончательную неудачу вызова / недоступность сервиса (ERROR).
|
||||||
|
func (c ExtCall) Failure(log *slog.Logger, err error, extra ...any) {
|
||||||
|
log.Error("external call failed", c.attrs(append([]any{"error", err}, extra...)...)...)
|
||||||
|
}
|
||||||
@@ -5,11 +5,12 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// New возвращает slog-логгер с указанным уровнем и форматом ("json"|"text").
|
// New возвращает slog-логгер с указанным уровнем и форматом ("json"|"text").
|
||||||
func New(level, format string) *slog.Logger {
|
func New(level, format string) *slog.Logger {
|
||||||
opts := &slog.HandlerOptions{Level: parseLevel(level)}
|
opts := &slog.HandlerOptions{Level: parseLevel(level), ReplaceAttr: utcTime}
|
||||||
|
|
||||||
var handler slog.Handler
|
var handler slog.Handler
|
||||||
if strings.EqualFold(format, "text") {
|
if strings.EqualFold(format, "text") {
|
||||||
@@ -20,6 +21,25 @@ func New(level, format string) *slog.Logger {
|
|||||||
return slog.New(handler)
|
return slog.New(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// utcTime приводит метку времени к UTC. JSONHandler сериализует time.Time в
|
||||||
|
// RFC3339 с долями секунды; в UTC суффикс — Z. Бизнес-логика остаётся в
|
||||||
|
// Europe/Moscow, UTC — только в логах (явный TZ, инвариант проекта не нарушен).
|
||||||
|
func utcTime(groups []string, a slog.Attr) slog.Attr {
|
||||||
|
if len(groups) == 0 && a.Key == slog.TimeKey {
|
||||||
|
if t, ok := a.Value.Any().(time.Time); ok {
|
||||||
|
a.Value = slog.TimeValue(t.UTC())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStderr — JSON-логгер в stderr (UTC) для фатальных ошибок старта сервиса,
|
||||||
|
// когда основной логгер ещё не собран (конфиг не прочитан). Уровень не
|
||||||
|
// ограничиваем — пишем сам факт фатального сбоя.
|
||||||
|
func NewStderr() *slog.Logger {
|
||||||
|
return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ReplaceAttr: utcTime}))
|
||||||
|
}
|
||||||
|
|
||||||
func parseLevel(level string) slog.Level {
|
func parseLevel(level string) slog.Level {
|
||||||
switch strings.ToLower(level) {
|
switch strings.ToLower(level) {
|
||||||
case "debug":
|
case "debug":
|
||||||
|
|||||||
+24
-27
@@ -10,6 +10,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultTimeout = 10 * time.Second
|
const defaultTimeout = 10 * time.Second
|
||||||
@@ -38,8 +41,9 @@ func newHTTPClient(proxy string, timeout time.Duration) (*http.Client, error) {
|
|||||||
const maxBody = 4 << 20 // 4 MiB — потолок на тело ответа
|
const maxBody = 4 << 20 // 4 MiB — потолок на тело ответа
|
||||||
|
|
||||||
// getJSON выполняет GET и декодирует JSON-ответ в out. headers — опц.
|
// getJSON выполняет GET и декодирует JSON-ответ в out. headers — опц.
|
||||||
// дополнительные заголовки (напр. Authorization).
|
// дополнительные заголовки (напр. Authorization). service/operation — поля
|
||||||
func getJSON(ctx context.Context, hc *http.Client, log *slog.Logger, rawURL string, headers map[string]string, out any) error {
|
// ext.* для телеметрии вызова.
|
||||||
|
func getJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, operation, rawURL string, headers map[string]string, out any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("metadata: build request: %w", err)
|
return fmt.Errorf("metadata: build request: %w", err)
|
||||||
@@ -48,11 +52,11 @@ func getJSON(ctx context.Context, hc *http.Client, log *slog.Logger, rawURL stri
|
|||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
return doJSON(hc, log, req, out)
|
return doJSON(ctx, hc, log, service, operation, req, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// postJSON выполняет POST с JSON-телом и декодирует ответ.
|
// postJSON выполняет POST с JSON-телом и декодирует ответ.
|
||||||
func postJSON(ctx context.Context, hc *http.Client, log *slog.Logger, rawURL string, body, out any) error {
|
func postJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, operation, rawURL string, body, out any) error {
|
||||||
payload, err := json.Marshal(body)
|
payload, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("metadata: marshal body: %w", err)
|
return fmt.Errorf("metadata: marshal body: %w", err)
|
||||||
@@ -63,46 +67,39 @@ func postJSON(ctx context.Context, hc *http.Client, log *slog.Logger, rawURL str
|
|||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
return doJSON(hc, log, req, out)
|
return doJSON(ctx, hc, log, service, operation, req, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// doJSON выполняет запрос и декодирует ответ, логируя исход. В лог идут только
|
// doJSON выполняет запрос и декодирует ответ, логируя исход телеметрией ext.*
|
||||||
// host и path (без query) — у TMDB api_key передаётся query-параметром, его
|
// (логическая operation вместо URL: у TMDB api_key передаётся query-параметром,
|
||||||
// нельзя светить в логах.
|
// его нельзя светить в логах). Логгер берётся из ctx (scoped-логгер загрузки),
|
||||||
func doJSON(hc *http.Client, log *slog.Logger, req *http.Request, out any) error {
|
// при отсутствии — переданный fallback.
|
||||||
if log == nil {
|
func doJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, operation string, req *http.Request, out any) error {
|
||||||
log = slog.Default()
|
log = logctx.FromOr(ctx, log)
|
||||||
}
|
call := logging.ExtCall{Service: service, Operation: operation, Start: time.Now()}
|
||||||
start := time.Now()
|
|
||||||
resp, err := hc.Do(req)
|
resp, err := hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("metadata: request failed",
|
call.Failure(log, err)
|
||||||
"method", req.Method, "host", req.URL.Host, "path", req.URL.Path,
|
|
||||||
"duration", time.Since(start), "err", err)
|
|
||||||
return fmt.Errorf("metadata: request: %w", err)
|
return fmt.Errorf("metadata: request: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
call.Status = resp.StatusCode
|
||||||
|
|
||||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("metadata: read body failed",
|
call.Failure(log, err)
|
||||||
"host", req.URL.Host, "path", req.URL.Path, "err", err)
|
|
||||||
return fmt.Errorf("metadata: read body: %w", err)
|
return fmt.Errorf("metadata: read body: %w", err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
log.Warn("metadata: non-ok status",
|
err := fmt.Errorf("metadata: status %d: %s", resp.StatusCode, snippet(raw))
|
||||||
"method", req.Method, "host", req.URL.Host, "path", req.URL.Path,
|
call.Failure(log, err)
|
||||||
"status", resp.StatusCode, "duration", time.Since(start))
|
return err
|
||||||
return fmt.Errorf("metadata: status %d: %s", resp.StatusCode, snippet(raw))
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, out); err != nil {
|
if err := json.Unmarshal(raw, out); err != nil {
|
||||||
log.Warn("metadata: decode failed",
|
call.Failure(log, err)
|
||||||
"host", req.URL.Host, "path", req.URL.Path, "err", err)
|
|
||||||
return fmt.Errorf("metadata: decode: %w (body: %s)", err, snippet(raw))
|
return fmt.Errorf("metadata: decode: %w (body: %s)", err, snippet(raw))
|
||||||
}
|
}
|
||||||
log.Debug("metadata: request ok",
|
call.Success(log)
|
||||||
"method", req.Method, "host", req.URL.Host, "path", req.URL.Path,
|
|
||||||
"duration", time.Since(start))
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
const tmdbDefaultBaseURL = "https://api.themoviedb.org/3"
|
const tmdbDefaultBaseURL = "https://api.themoviedb.org/3"
|
||||||
@@ -81,12 +83,11 @@ func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
|||||||
return nil, fmt.Errorf("metadata: tmdb: unknown type %q", q.Type)
|
return nil, fmt.Errorf("metadata: tmdb: unknown type %q", q.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.log.Debug("tmdb: search", "type", q.Type, "title", q.Title, "year", q.Year)
|
|
||||||
var resp tmdbSearchResp
|
var resp tmdbSearchResp
|
||||||
if err := getJSON(ctx, t.hc, t.log, t.baseURL+path+"?"+params.Encode(), nil, &resp); err != nil {
|
op := strings.TrimPrefix(path, "/") // search/movie | search/tv
|
||||||
|
if err := getJSON(ctx, t.hc, t.log, logging.ServiceTMDB, op, t.baseURL+path+"?"+params.Encode(), nil, &resp); err != nil {
|
||||||
return nil, fmt.Errorf("tmdb search: %w", err)
|
return nil, fmt.Errorf("tmdb search: %w", err)
|
||||||
}
|
}
|
||||||
t.log.Debug("tmdb: search done", "title", q.Title, "results", len(resp.Results))
|
|
||||||
|
|
||||||
out := make([]Candidate, 0, len(resp.Results))
|
out := make([]Candidate, 0, len(resp.Results))
|
||||||
for _, r := range resp.Results {
|
for _, r := range resp.Results {
|
||||||
@@ -116,7 +117,7 @@ type tmdbTVResp struct {
|
|||||||
func (t *TMDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
|
func (t *TMDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
|
||||||
params := url.Values{"api_key": {t.apiKey}}
|
params := url.Values{"api_key": {t.apiKey}}
|
||||||
var resp tmdbTVResp
|
var resp tmdbTVResp
|
||||||
if err := getJSON(ctx, t.hc, t.log, t.baseURL+"/tv/"+url.PathEscape(id)+"?"+params.Encode(), nil, &resp); err != nil {
|
if err := getJSON(ctx, t.hc, t.log, logging.ServiceTMDB, "tv", t.baseURL+"/tv/"+url.PathEscape(id)+"?"+params.Encode(), nil, &resp); err != nil {
|
||||||
return nil, fmt.Errorf("tmdb tv %s: %w", id, err)
|
return nil, fmt.Errorf("tmdb tv %s: %w", id, err)
|
||||||
}
|
}
|
||||||
out := make(map[int]int, len(resp.Seasons))
|
out := make(map[int]int, len(resp.Seasons))
|
||||||
|
|||||||
+20
-17
@@ -12,6 +12,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
const tvdbDefaultBaseURL = "https://api4.thetvdb.com/v4"
|
const tvdbDefaultBaseURL = "https://api4.thetvdb.com/v4"
|
||||||
@@ -70,8 +73,8 @@ func (t *TVDB) login(ctx context.Context) (string, error) {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
t.log.Debug("tvdb: login (fetching bearer token)")
|
// Тело запроса содержит apikey — postJSON его не логирует (только ext.*).
|
||||||
if err := postJSON(ctx, t.hc, t.log, t.baseURL+"/login",
|
if err := postJSON(ctx, t.hc, t.log, logging.ServiceTVDB, "login", t.baseURL+"/login",
|
||||||
map[string]string{"apikey": t.apiKey}, &resp); err != nil {
|
map[string]string{"apikey": t.apiKey}, &resp); err != nil {
|
||||||
return "", fmt.Errorf("tvdb login: %w", err)
|
return "", fmt.Errorf("tvdb login: %w", err)
|
||||||
}
|
}
|
||||||
@@ -83,24 +86,26 @@ func (t *TVDB) login(ctx context.Context) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// get делает авторизованный GET; при 401 один раз перелогинивается.
|
// get делает авторизованный GET; при 401 один раз перелогинивается.
|
||||||
func (t *TVDB) get(ctx context.Context, path string, out any) error {
|
// operation — логическая операция для поля ext.operation.
|
||||||
|
func (t *TVDB) get(ctx context.Context, operation, path string, out any) error {
|
||||||
token, err := t.login(ctx)
|
token, err := t.login(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
status, raw, err := t.rawGet(ctx, path, token)
|
status, raw, err := t.rawGet(ctx, operation, path, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if status == http.StatusUnauthorized {
|
if status == http.StatusUnauthorized {
|
||||||
t.log.Warn("tvdb: token expired, re-login", "path", path)
|
// Рутинное обновление протухшего токена — DEBUG (не «может стать проблемой»).
|
||||||
|
logctx.FromOr(ctx, t.log).Debug("tvdb token expired, re-login")
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
t.token = "" // сбрасываем протухший токен
|
t.token = "" // сбрасываем протухший токен
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
if token, err = t.login(ctx); err != nil {
|
if token, err = t.login(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if status, raw, err = t.rawGet(ctx, path, token); err != nil {
|
if status, raw, err = t.rawGet(ctx, operation, path, token); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,28 +118,28 @@ func (t *TVDB) get(ctx context.Context, path string, out any) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TVDB) rawGet(ctx context.Context, path, token string) (int, []byte, error) {
|
func (t *TVDB) rawGet(ctx context.Context, operation, path, token string) (int, []byte, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.baseURL+path, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.baseURL+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, nil, fmt.Errorf("tvdb: build request: %w", err)
|
return 0, nil, fmt.Errorf("tvdb: build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
start := time.Now()
|
log := logctx.FromOr(ctx, t.log)
|
||||||
|
call := logging.ExtCall{Service: logging.ServiceTVDB, Operation: operation, Start: time.Now()}
|
||||||
resp, err := t.hc.Do(req)
|
resp, err := t.hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.log.Warn("tvdb: request failed",
|
call.Failure(log, err)
|
||||||
"host", req.URL.Host, "path", req.URL.Path, "duration", time.Since(start), "err", err)
|
|
||||||
return 0, nil, fmt.Errorf("tvdb: request: %w", err)
|
return 0, nil, fmt.Errorf("tvdb: request: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
call.Status = resp.StatusCode
|
||||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.log.Warn("tvdb: read body failed", "host", req.URL.Host, "path", req.URL.Path, "err", err)
|
call.Failure(log, err)
|
||||||
return 0, nil, fmt.Errorf("tvdb: read body: %w", err)
|
return 0, nil, fmt.Errorf("tvdb: read body: %w", err)
|
||||||
}
|
}
|
||||||
t.log.Debug("tvdb: request done",
|
call.Success(log)
|
||||||
"host", req.URL.Host, "path", req.URL.Path, "status", resp.StatusCode, "duration", time.Since(start))
|
|
||||||
return resp.StatusCode, raw, nil
|
return resp.StatusCode, raw, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,12 +161,10 @@ func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
|||||||
if q.Year > 0 {
|
if q.Year > 0 {
|
||||||
params.Set("year", strconv.Itoa(q.Year))
|
params.Set("year", strconv.Itoa(q.Year))
|
||||||
}
|
}
|
||||||
t.log.Debug("tvdb: search", "type", q.Type, "title", q.Title, "year", q.Year)
|
|
||||||
var resp tvdbSearchResp
|
var resp tvdbSearchResp
|
||||||
if err := t.get(ctx, "/search?"+params.Encode(), &resp); err != nil {
|
if err := t.get(ctx, "search", "/search?"+params.Encode(), &resp); err != nil {
|
||||||
return nil, fmt.Errorf("tvdb search: %w", err)
|
return nil, fmt.Errorf("tvdb search: %w", err)
|
||||||
}
|
}
|
||||||
t.log.Debug("tvdb: search done", "title", q.Title, "results", len(resp.Data))
|
|
||||||
out := make([]Candidate, 0, len(resp.Data))
|
out := make([]Candidate, 0, len(resp.Data))
|
||||||
for _, r := range resp.Data {
|
for _, r := range resp.Data {
|
||||||
if r.TVDBID == "" {
|
if r.TVDBID == "" {
|
||||||
@@ -189,7 +192,7 @@ type tvdbExtendedResp struct {
|
|||||||
// SeasonEpisodeCounts считает число серий по сезонам из расширенных данных.
|
// SeasonEpisodeCounts считает число серий по сезонам из расширенных данных.
|
||||||
func (t *TVDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
|
func (t *TVDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
|
||||||
var resp tvdbExtendedResp
|
var resp tvdbExtendedResp
|
||||||
if err := t.get(ctx, "/series/"+url.PathEscape(id)+"/extended?meta=episodes&short=true", &resp); err != nil {
|
if err := t.get(ctx, "series/extended", "/series/"+url.PathEscape(id)+"/extended?meta=episodes&short=true", &resp); err != nil {
|
||||||
return nil, fmt.Errorf("tvdb series %s: %w", id, err)
|
return nil, fmt.Errorf("tvdb series %s: %w", id, err)
|
||||||
}
|
}
|
||||||
out := map[int]int{}
|
out := map[int]int{}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
const tvmazeDefaultBaseURL = "https://api.tvmaze.com"
|
const tvmazeDefaultBaseURL = "https://api.tvmaze.com"
|
||||||
@@ -68,11 +70,9 @@ func (t *TVMaze) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
|||||||
Show tvmazeShow `json:"show"`
|
Show tvmazeShow `json:"show"`
|
||||||
}
|
}
|
||||||
rawURL := t.baseURL + "/search/shows?q=" + url.QueryEscape(q.Title)
|
rawURL := t.baseURL + "/search/shows?q=" + url.QueryEscape(q.Title)
|
||||||
t.log.Debug("tvmaze: search", "title", q.Title)
|
if err := getJSON(ctx, t.hc, t.log, logging.ServiceTVMaze, "search/shows", rawURL, nil, &resp); err != nil {
|
||||||
if err := getJSON(ctx, t.hc, t.log, rawURL, nil, &resp); err != nil {
|
|
||||||
return nil, fmt.Errorf("tvmaze search: %w", err)
|
return nil, fmt.Errorf("tvmaze search: %w", err)
|
||||||
}
|
}
|
||||||
t.log.Debug("tvmaze: search done", "title", q.Title, "results", len(resp))
|
|
||||||
|
|
||||||
out := make([]Candidate, 0, len(resp))
|
out := make([]Candidate, 0, len(resp))
|
||||||
for _, r := range resp {
|
for _, r := range resp {
|
||||||
@@ -104,7 +104,7 @@ type tvmazeEpisode struct {
|
|||||||
func (t *TVMaze) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
|
func (t *TVMaze) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
|
||||||
var eps []tvmazeEpisode
|
var eps []tvmazeEpisode
|
||||||
rawURL := t.baseURL + "/shows/" + url.PathEscape(id) + "/episodes"
|
rawURL := t.baseURL + "/shows/" + url.PathEscape(id) + "/episodes"
|
||||||
if err := getJSON(ctx, t.hc, t.log, rawURL, nil, &eps); err != nil {
|
if err := getJSON(ctx, t.hc, t.log, logging.ServiceTVMaze, "shows/episodes", rawURL, nil, &eps); err != nil {
|
||||||
return nil, fmt.Errorf("tvmaze episodes %s: %w", id, err)
|
return nil, fmt.Errorf("tvmaze episodes %s: %w", id, err)
|
||||||
}
|
}
|
||||||
out := map[int]int{}
|
out := map[int]int{}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// systemPrompt инструктирует модель вытащить из вольного контекста короткое
|
// systemPrompt инструктирует модель вытащить из вольного контекста короткое
|
||||||
@@ -56,6 +57,7 @@ func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (ex
|
|||||||
{Role: llm.RoleUser, Content: user},
|
{Role: llm.RoleUser, Content: user},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log := logctx.FromOr(ctx, n.log)
|
||||||
for attempt := 1; attempt <= n.attempts; attempt++ {
|
for attempt := 1; attempt <= n.attempts; attempt++ {
|
||||||
resp, err := n.provider.Complete(ctx, llm.Request{
|
resp, err := n.provider.Complete(ctx, llm.Request{
|
||||||
Messages: msgs,
|
Messages: msgs,
|
||||||
@@ -63,9 +65,9 @@ func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (ex
|
|||||||
Temperature: &temp,
|
Temperature: &temp,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Транспортная ошибка/таймаут: дальше пробовать смысла нет —
|
// Транспортная ошибка/таймаут залогирована клиентом LLM (ext.*);
|
||||||
// уходим в фолбек, приём не валим.
|
// здесь — доменное решение «уходим в фолбек, приём не валим».
|
||||||
n.log.Warn("naming: llm complete failed, will fall back", "err", err)
|
log.Debug("naming llm failed, using fallback")
|
||||||
return extracted{}, false
|
return extracted{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (ex
|
|||||||
if perr == nil {
|
if perr == nil {
|
||||||
return ex, true
|
return ex, true
|
||||||
}
|
}
|
||||||
n.log.Warn("naming: unparsed llm response", "attempt", attempt, "err", perr)
|
log.Warn("naming llm response unparsed", "attempt", attempt, "error", perr)
|
||||||
msgs = append(msgs,
|
msgs = append(msgs,
|
||||||
llm.Message{Role: llm.RoleAssistant, Content: resp.Content},
|
llm.Message{Role: llm.RoleAssistant, Content: resp.Content},
|
||||||
llm.Message{Role: llm.RoleUser, Content: "Ответ невалиден: " + perr.Error() +
|
llm.Message{Role: llm.RoleUser, Content: "Ответ невалиден: " + perr.Error() +
|
||||||
|
|||||||
+45
-15
@@ -21,6 +21,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config — параметры подключения к qBittorrent WebUI.
|
// Config — параметры подключения к qBittorrent WebUI.
|
||||||
@@ -119,19 +122,25 @@ func (c *Client) login(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.Header.Set("Referer", c.base.String()) // qBit проверяет Referer/Host
|
req.Header.Set("Referer", c.base.String()) // qBit проверяет Referer/Host
|
||||||
|
log := logctx.FromOr(ctx, c.log)
|
||||||
|
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "auth/login", Start: time.Now()}
|
||||||
resp, err := c.hc.Do(req)
|
resp, err := c.hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
call.Failure(log, err)
|
||||||
return fmt.Errorf("qbittorrent login: %w", err)
|
return fmt.Errorf("qbittorrent login: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
call.Status = resp.StatusCode
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||||
if resp.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != "Ok." {
|
if resp.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != "Ok." {
|
||||||
c.log.Error("qbittorrent: login failed",
|
// Тело логина не содержит секретов (qBit отвечает "Ok."/"Fails."), но
|
||||||
"status", resp.StatusCode, "body", strings.TrimSpace(string(body)))
|
// учётные данные (логин/пароль) в лог не идут — только факт неуспеха.
|
||||||
return fmt.Errorf("qbittorrent login failed: status %d body %q",
|
err := fmt.Errorf("qbittorrent login failed: status %d body %q",
|
||||||
resp.StatusCode, strings.TrimSpace(string(body)))
|
resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
call.Failure(log, err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
c.log.Debug("qbittorrent: login ok", "user", c.user)
|
call.Success(log, "authenticated", true)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +157,7 @@ func (c *Client) do(ctx context.Context, build func() (*http.Request, error)) (*
|
|||||||
}
|
}
|
||||||
if resp.StatusCode == http.StatusForbidden {
|
if resp.StatusCode == http.StatusForbidden {
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
c.log.Debug("qbittorrent: session expired (403), re-login")
|
logctx.FromOr(ctx, c.log).Debug("qbittorrent session expired, re-login")
|
||||||
if err := c.login(ctx); err != nil {
|
if err := c.login(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -193,6 +202,8 @@ func (c *Client) Add(ctx context.Context, ar AddRequest) error {
|
|||||||
contentType := mw.FormDataContentType()
|
contentType := mw.FormDataContentType()
|
||||||
payload := buf.Bytes()
|
payload := buf.Bytes()
|
||||||
|
|
||||||
|
log := logctx.FromOr(ctx, c.log)
|
||||||
|
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/add", Start: time.Now()}
|
||||||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
c.endpoint("/api/v2/torrents/add"), bytes.NewReader(payload))
|
c.endpoint("/api/v2/torrents/add"), bytes.NewReader(payload))
|
||||||
@@ -204,27 +215,33 @@ func (c *Client) Add(ctx context.Context, ar AddRequest) error {
|
|||||||
return req, nil
|
return req, nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
call.Failure(log, err, "category", ar.Category)
|
||||||
return fmt.Errorf("qbittorrent add: %w", err)
|
return fmt.Errorf("qbittorrent add: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
call.Status = resp.StatusCode
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("qbittorrent add: status %d body %q",
|
err := fmt.Errorf("qbittorrent add: status %d body %q",
|
||||||
resp.StatusCode, strings.TrimSpace(string(body)))
|
resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
call.Failure(log, err, "category", ar.Category)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(string(body)) == "Fails." {
|
if strings.TrimSpace(string(body)) == "Fails." {
|
||||||
c.log.Error("qbittorrent: add rejected",
|
err := fmt.Errorf("qbittorrent add: rejected (Fails.)")
|
||||||
"category", ar.Category, "urls", len(ar.URLs), "torrents", len(ar.Torrents))
|
call.Failure(log, err, "category", ar.Category,
|
||||||
return fmt.Errorf("qbittorrent add: rejected (Fails.)")
|
"urls", len(ar.URLs), "torrents", len(ar.Torrents))
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
c.log.Info("qbittorrent: torrent added",
|
call.Success(log, "category", ar.Category, "save_path", ar.SavePath,
|
||||||
"category", ar.Category, "save_path", ar.SavePath,
|
|
||||||
"urls", len(ar.URLs), "torrents", len(ar.Torrents), "paused", ar.Paused)
|
"urls", len(ar.URLs), "torrents", len(ar.Torrents), "paused", ar.Paused)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Torrents возвращает задачи указанной категории (пустая — все).
|
// Torrents возвращает задачи указанной категории (пустая — все).
|
||||||
func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) {
|
func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) {
|
||||||
|
log := logctx.FromOr(ctx, c.log)
|
||||||
|
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/info", Start: time.Now()}
|
||||||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||||||
u := c.endpoint("/api/v2/torrents/info")
|
u := c.endpoint("/api/v2/torrents/info")
|
||||||
if category != "" {
|
if category != "" {
|
||||||
@@ -233,19 +250,25 @@ func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, erro
|
|||||||
return http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
return http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
call.Failure(log, err)
|
||||||
return nil, fmt.Errorf("qbittorrent info: %w", err)
|
return nil, fmt.Errorf("qbittorrent info: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
call.Status = resp.StatusCode
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||||
return nil, fmt.Errorf("qbittorrent info: status %d body %q",
|
err := fmt.Errorf("qbittorrent info: status %d body %q",
|
||||||
resp.StatusCode, strings.TrimSpace(string(body)))
|
resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
call.Failure(log, err)
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
var ts []Torrent
|
var ts []Torrent
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&ts); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&ts); err != nil {
|
||||||
|
call.Failure(log, err)
|
||||||
return nil, fmt.Errorf("decode qbittorrent info: %w", err)
|
return nil, fmt.Errorf("decode qbittorrent info: %w", err)
|
||||||
}
|
}
|
||||||
c.log.Debug("qbittorrent: torrents fetched", "category", category, "count", len(ts))
|
// Поллинг частый — на DEBUG, чтобы не зашумлять INFO (как healthcheck).
|
||||||
|
call.SuccessDebug(log, "category", category, "count", len(ts))
|
||||||
return ts, nil
|
return ts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,23 +276,30 @@ func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, erro
|
|||||||
// включая корневую папку для многофайловых раздач, и размеры). Нужен
|
// включая корневую папку для многофайловых раздач, и размеры). Нужен
|
||||||
// распознаванию как один из сигналов; абсолютный путь — join(save_path, Name).
|
// распознаванию как один из сигналов; абсолютный путь — join(save_path, Name).
|
||||||
func (c *Client) Files(ctx context.Context, hash string) ([]File, error) {
|
func (c *Client) Files(ctx context.Context, hash string) ([]File, error) {
|
||||||
|
log := logctx.FromOr(ctx, c.log)
|
||||||
|
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/files", Start: time.Now()}
|
||||||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||||||
u := c.endpoint("/api/v2/torrents/files?hash=" + url.QueryEscape(hash))
|
u := c.endpoint("/api/v2/torrents/files?hash=" + url.QueryEscape(hash))
|
||||||
return http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
return http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
call.Failure(log, err)
|
||||||
return nil, fmt.Errorf("qbittorrent files: %w", err)
|
return nil, fmt.Errorf("qbittorrent files: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
call.Status = resp.StatusCode
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||||
return nil, fmt.Errorf("qbittorrent files: status %d body %q",
|
err := fmt.Errorf("qbittorrent files: status %d body %q",
|
||||||
resp.StatusCode, strings.TrimSpace(string(body)))
|
resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
call.Failure(log, err)
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
var fs []File
|
var fs []File
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&fs); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&fs); err != nil {
|
||||||
|
call.Failure(log, err)
|
||||||
return nil, fmt.Errorf("decode qbittorrent files: %w", err)
|
return nil, fmt.Errorf("decode qbittorrent files: %w", err)
|
||||||
}
|
}
|
||||||
c.log.Debug("qbittorrent: files fetched", "hash", hash, "count", len(fs))
|
call.SuccessDebug(log, "count", len(fs))
|
||||||
return fs, nil
|
return fs, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,7 +39,9 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
|
|||||||
for _, p := range r.providers {
|
for _, p := range r.providers {
|
||||||
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year})
|
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.log.Warn("recognize: metadata search failed", "provider", p.Name(), "err", err)
|
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
|
||||||
|
// доменное решение «пропускаем провайдера, пробуем следующий».
|
||||||
|
logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +76,7 @@ func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c meta
|
|||||||
if got, err := p.SeasonEpisodeCounts(ctx, c.ID); err == nil {
|
if got, err := p.SeasonEpisodeCounts(ctx, c.ID); err == nil {
|
||||||
counts = got
|
counts = got
|
||||||
} else {
|
} else {
|
||||||
r.log.Warn("recognize: episode counts failed", "provider", p.Name(), "id", c.ID, "err", err)
|
logctx.FromOr(ctx, r.log).Debug("metadata episode counts skipped", "provider", p.Name(), "id", c.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prov, pid := CandidateTag(c)
|
prov, pid := CandidateTag(c)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -188,6 +189,7 @@ func New(provider LLM, providers []metadata.Provider, cfg Config, log *slog.Logg
|
|||||||
// error (наверху решат retry/failed). Неразобранный после ретраев ответ —
|
// error (наверху решат retry/failed). Неразобранный после ретраев ответ —
|
||||||
// не ошибка, а Result с решением review (см. recognition.md).
|
// не ошибка, а Result с решением review (см. recognition.md).
|
||||||
func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
||||||
|
log := logctx.FromOr(ctx, r.log)
|
||||||
pre := preParse(in.Name)
|
pre := preParse(in.Name)
|
||||||
msgs := buildMessages(in, pre, r.maxFiles)
|
msgs := buildMessages(in, pre, r.maxFiles)
|
||||||
|
|
||||||
@@ -214,8 +216,8 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
|||||||
if parseErr == nil {
|
if parseErr == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
r.log.Warn("recognize: unparsed llm response",
|
log.Warn("recognition llm response unparsed",
|
||||||
"attempt", attempts, "err", parseErr)
|
"attempt", attempts, "error", parseErr)
|
||||||
// Просим модель исправиться, повторяя схему и ошибку.
|
// Просим модель исправиться, повторяя схему и ошибку.
|
||||||
msgs = append(msgs,
|
msgs = append(msgs,
|
||||||
llm.Message{Role: llm.RoleAssistant, Content: raw},
|
llm.Message{Role: llm.RoleAssistant, Content: raw},
|
||||||
@@ -246,8 +248,8 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold)
|
dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold)
|
||||||
r.log.Info("recognize: done",
|
log.Info("recognition done",
|
||||||
"type", plan.Type, "title", plan.Title, "year", plan.Year,
|
"media_type", plan.Type, "title", plan.Title, "year", plan.Year,
|
||||||
"files", len(plan.Files), "attempts", attempts,
|
"files", len(plan.Files), "attempts", attempts,
|
||||||
"matched", match != nil, "candidates", len(candidates),
|
"matched", match != nil, "candidates", len(candidates),
|
||||||
"auto", dec.Auto, "reasons", len(dec.Reasons))
|
"auto", dec.Auto, "reasons", len(dec.Reasons))
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func parsePlan(raw string, in Input) (Plan, error) {
|
|||||||
// Повторяем без строгого режима: лишние поля — не повод падать,
|
// Повторяем без строгого режима: лишние поля — не повод падать,
|
||||||
// но если и так не разобралось — это ошибка схемы.
|
// но если и так не разобралось — это ошибка схемы.
|
||||||
if err2 := json.Unmarshal([]byte(jsonStr), &p); err2 != nil {
|
if err2 := json.Unmarshal([]byte(jsonStr), &p); err2 != nil {
|
||||||
return Plan{}, fmt.Errorf("JSON not parsed: %v", err2)
|
return Plan{}, fmt.Errorf("JSON not parsed: %w", err2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ VALUES (?, ?, ?, ?, ?, ?)`
|
|||||||
func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
|
func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
|
||||||
var d Download
|
var d Download
|
||||||
if err := s.DB.GetContext(ctx, &d, `SELECT * FROM download WHERE id = ?`, id); err != nil {
|
if err := s.DB.GetContext(ctx, &d, `SELECT * FROM download WHERE id = ?`, id); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("get download %d: %w", id, ErrNotFound)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("get download %d: %w", id, err)
|
return nil, fmt.Errorf("get download %d: %w", id, err)
|
||||||
}
|
}
|
||||||
return &d, nil
|
return &d, nil
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrNotFound — доменный sentinel «запись не найдена». Слой store транслирует
|
||||||
|
// в него sql.ErrNoRows у источника, чтобы выше по коду не торчал database/sql,
|
||||||
|
// а потребители матчили причину через errors.Is(err, store.ErrNotFound).
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID int64) (*R
|
|||||||
err := s.DB.GetContext(ctx, &r,
|
err := s.DB.GetContext(ctx, &r,
|
||||||
`SELECT * FROM recognition WHERE download_id = ? AND is_current = 1
|
`SELECT * FROM recognition WHERE download_id = ? AND is_current = 1
|
||||||
ORDER BY attempt_no DESC LIMIT 1`, downloadID)
|
ORDER BY attempt_no DESC LIMIT 1`, downloadID)
|
||||||
if err == sql.ErrNoRows {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -201,7 +202,7 @@ func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (string, er
|
|||||||
err := s.DB.GetContext(ctx, &batch,
|
err := s.DB.GetContext(ctx, &batch,
|
||||||
`SELECT apply_batch_id FROM file_link WHERE download_id = ?
|
`SELECT apply_batch_id FROM file_link WHERE download_id = ?
|
||||||
ORDER BY id DESC LIMIT 1`, downloadID)
|
ORDER BY id DESC LIMIT 1`, downloadID)
|
||||||
if err == sql.ErrNoRows {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -285,7 +286,7 @@ func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID i
|
|||||||
func (s *Store) GetCandidate(ctx context.Context, id int64) (*MetadataCandidate, error) {
|
func (s *Store) GetCandidate(ctx context.Context, id int64) (*MetadataCandidate, error) {
|
||||||
var c MetadataCandidate
|
var c MetadataCandidate
|
||||||
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
|
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
|
||||||
if err == sql.ErrNoRows {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+23
-9
@@ -116,7 +116,7 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !b.allowed[m.From.ID] {
|
if !b.allowed[m.From.ID] {
|
||||||
b.log.Warn("telegram: denied user", "user_id", m.From.ID, "username", m.From.UserName)
|
b.log.Warn("telegram denied user", "user_id", m.From.ID, "username", m.From.UserName)
|
||||||
b.send(m.Chat.ID, "Доступ запрещён.", nil)
|
b.send(m.Chat.ID, "Доступ запрещён.", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
|||||||
// Ждём подсказку для перераспознавания?
|
// Ждём подсказку для перераспознавания?
|
||||||
if id, ok := b.takePending(m.Chat.ID); ok && !strings.Contains(text, "magnet:") {
|
if id, ok := b.takePending(m.Chat.ID); ok && !strings.Contains(text, "magnet:") {
|
||||||
if err := b.reviewer.Refine(ctx, id, text); err != nil {
|
if err := b.reviewer.Refine(ctx, id, text); err != nil {
|
||||||
b.send(m.Chat.ID, "Не удалось: "+err.Error(), nil)
|
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+strconv.FormatInt(id, 10)+"…", nil)
|
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+strconv.FormatInt(id, 10)+"…", nil)
|
||||||
@@ -144,7 +144,10 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
|||||||
}
|
}
|
||||||
res, err := b.ingestor.Ingest(ctx, ingest.Request{Source: source, Context: context})
|
res, err := b.ingestor.Ingest(ctx, ingest.Request{Source: source, Context: context})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.send(m.Chat.ID, "Ошибка приёма: "+err.Error(), nil)
|
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit);
|
||||||
|
// при раннем разборе источника id ещё нет — даём дружелюбный текст без
|
||||||
|
// него (детали всё равно в логах на доменной границе).
|
||||||
|
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := fmt.Sprintf("Принято #%d — %s.", res.DownloadID, res.State)
|
msg := fmt.Sprintf("Принято #%d — %s.", res.DownloadID, res.State)
|
||||||
@@ -203,7 +206,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.answer(cq.ID, "Ошибка")
|
b.answer(cq.ID, "Ошибка")
|
||||||
b.send(chatID, "Не удалось: "+err.Error(), nil)
|
b.send(chatID, opErr("Не удалось выполнить действие", id), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b.answer(cq.ID, note)
|
b.answer(cq.ID, note)
|
||||||
@@ -214,7 +217,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
|||||||
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64) {
|
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64) {
|
||||||
rd, err := b.reviewer.ReviewData(ctx, id)
|
rd, err := b.reviewer.ReviewData(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.log.Warn("telegram: refresh card failed", "download_id", id, "err", err)
|
b.log.Warn("telegram refresh card failed", "download_id", id, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
text, kb := b.renderCard(rd)
|
text, kb := b.renderCard(rd)
|
||||||
@@ -225,7 +228,7 @@ func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64
|
|||||||
edit = tgbotapi.NewEditMessageText(chatID, msgID, text)
|
edit = tgbotapi.NewEditMessageText(chatID, msgID, text)
|
||||||
}
|
}
|
||||||
if _, err := b.api.Send(edit); err != nil {
|
if _, err := b.api.Send(edit); err != nil {
|
||||||
b.log.Warn("telegram: edit card failed", "download_id", id, "err", err)
|
b.log.Warn("telegram edit card failed", "download_id", id, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +238,7 @@ func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64
|
|||||||
func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyEvent) {
|
func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyEvent) {
|
||||||
rd, err := b.reviewer.ReviewData(ctx, downloadID)
|
rd, err := b.reviewer.ReviewData(ctx, downloadID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.log.Warn("telegram: notify review data", "download_id", downloadID, "err", err)
|
b.log.Warn("telegram notify review data", "download_id", downloadID, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var text string
|
var text string
|
||||||
@@ -260,13 +263,13 @@ func (b *Bot) send(chatID int64, text string, kb *tgbotapi.InlineKeyboardMarkup)
|
|||||||
msg.ReplyMarkup = *kb
|
msg.ReplyMarkup = *kb
|
||||||
}
|
}
|
||||||
if _, err := b.api.Send(msg); err != nil {
|
if _, err := b.api.Send(msg); err != nil {
|
||||||
b.log.Warn("telegram: send failed", "chat_id", chatID, "err", err)
|
b.log.Warn("telegram send failed", "chat_id", chatID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) answer(callbackID, text string) {
|
func (b *Bot) answer(callbackID, text string) {
|
||||||
if _, err := b.api.Request(tgbotapi.NewCallback(callbackID, text)); err != nil {
|
if _, err := b.api.Request(tgbotapi.NewCallback(callbackID, text)); err != nil {
|
||||||
b.log.Warn("telegram: answer callback failed", "err", err)
|
b.log.Warn("telegram answer callback failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +289,17 @@ func (b *Bot) takePending(chatID int64) (int64, bool) {
|
|||||||
return id, ok
|
return id, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// opErr — сообщение публичного канала Telegram по доменной ошибке: нейтральный
|
||||||
|
// текст + download_id для корреляции с логами (полная ошибка уже там, на
|
||||||
|
// доменной границе). Сырой err.Error() пользователю не показываем. Если id
|
||||||
|
// операции ещё нет (downloadID == 0) — дружелюбный текст без ключа.
|
||||||
|
func opErr(msg string, downloadID int64) string {
|
||||||
|
if downloadID > 0 {
|
||||||
|
return fmt.Sprintf("%s (download_id=%d).", msg, downloadID)
|
||||||
|
}
|
||||||
|
return msg + "."
|
||||||
|
}
|
||||||
|
|
||||||
// parseCallback разбирает "action[:id[:value]]".
|
// parseCallback разбирает "action[:id[:value]]".
|
||||||
func parseCallback(data string) (action string, id int64, value string) {
|
func parseCallback(data string) (action string, id int64, value string) {
|
||||||
parts := strings.Split(data, ":")
|
parts := strings.Split(data, ":")
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
|||||||
}
|
}
|
||||||
exists, err := w.store.ExistsByInfohash(ctx, infohash)
|
exists, err := w.store.ExistsByInfohash(ctx, infohash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.log.Warn("discover: exists check failed", "infohash", infohash, "err", err)
|
w.log.Warn("discover exists check failed", "capability", capIngest, "infohash", infohash, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if exists {
|
if exists {
|
||||||
@@ -61,11 +61,11 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
|||||||
if ex, _ := w.store.ExistsByInfohash(ctx, infohash); ex {
|
if ex, _ := w.store.ExistsByInfohash(ctx, infohash); ex {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.log.Error("discover: adopt failed", "infohash", infohash, "err", err)
|
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", infohash, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.log.Info("discover: adopted torrent",
|
w.log.Info("discover adopted torrent",
|
||||||
"download_id", id, "infohash", infohash, "name", t.Name,
|
"capability", capIngest, "download_id", id, "infohash", infohash, "name", t.Name,
|
||||||
"category", t.Category, "tags", t.Tags)
|
"category", t.Category, "tags", t.Tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrConflict — операция недопустима в текущем состоянии загрузки (напр. apply
|
||||||
|
// вне review/deferred, undo вне done). Это нормальный конфликт состояния, а не
|
||||||
|
// сбой сервера: транспорт матчит его через errors.Is и отвечает 409, не 500.
|
||||||
|
var ErrConflict = errors.New("conflict")
|
||||||
+57
-41
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"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/metadata"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
@@ -37,7 +38,7 @@ func (w *Worker) recognizePending(ctx context.Context) {
|
|||||||
pending, err := w.store.ListDownloadsByState(ctx, store.StateCompleted, store.StateRecognizing)
|
pending, err := w.store.ListDownloadsByState(ctx, store.StateCompleted, store.StateRecognizing)
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.log.Warn("recognize: list pending failed", "err", err)
|
w.log.Warn("recognition list pending failed", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, d := range pending {
|
for _, d := range pending {
|
||||||
@@ -54,13 +55,14 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
|||||||
d, err := w.store.GetDownload(ctx, id)
|
d, err := w.store.GetDownload(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
w.log.Warn("recognize: get download", "download_id", id, "err", err)
|
w.log.Warn("recognition get download failed", "download_id", id, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if d.State != store.StateCompleted && d.State != store.StateRecognizing {
|
if d.State != store.StateCompleted && d.State != store.StateRecognizing {
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ctx = w.scoped(ctx, capRecognize, id, d.Infohash.String)
|
||||||
if d.State == store.StateCompleted {
|
if d.State == store.StateCompleted {
|
||||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||||
}
|
}
|
||||||
@@ -68,8 +70,9 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
|||||||
|
|
||||||
result, savePath, err := w.runRecognize(ctx, *d)
|
result, savePath, err := w.runRecognize(ctx, *d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Не смогли получить сигналы или вызвать LLM — уходим в review с
|
// Граница доменной стадии распознавания: логируем исход один раз (ERROR),
|
||||||
// причиной, человек перезапустит подсказкой.
|
// дальше уходим в review с причиной — человек перезапустит подсказкой.
|
||||||
|
logctx.From(ctx).Error("recognition failed", "error", err)
|
||||||
result = recognize.Result{Decision: recognize.Decision{
|
result = recognize.Result{Decision: recognize.Decision{
|
||||||
Reasons: []string{"распознавание не удалось: " + err.Error()},
|
Reasons: []string{"распознавание не удалось: " + err.Error()},
|
||||||
}}
|
}}
|
||||||
@@ -121,9 +124,10 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
|
|||||||
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
|
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
|
||||||
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review.
|
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review.
|
||||||
func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.Result, savePath string) {
|
func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.Result, savePath string) {
|
||||||
|
log := logctx.From(ctx)
|
||||||
planJSON, err := json.Marshal(res.Plan)
|
planJSON, err := json.Marshal(res.Plan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.log.Error("recognize: marshal plan", "download_id", id, "err", err)
|
log.Error("recognition marshal plan failed", "error", err)
|
||||||
planJSON = []byte("{}")
|
planJSON = []byte("{}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,24 +161,23 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
|
|||||||
|
|
||||||
d, err := w.store.GetDownload(ctx, id)
|
d, err := w.store.GetDownload(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.log.Warn("recognize: reload download", "download_id", id, "err", err)
|
log.Warn("recognition reload download failed", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if d.State != store.StateRecognizing {
|
if d.State != store.StateRecognizing {
|
||||||
// За время вызова LLM задачу увели (cancel/defer) — результат не нужен.
|
// За время вызова LLM задачу увели (cancel/defer) — результат не нужен.
|
||||||
w.log.Info("recognize: result discarded, state changed",
|
log.Info("recognition result discarded", "state", d.State)
|
||||||
"download_id", id, "state", d.State)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recID, err := w.store.CreateRecognition(ctx, rec, res.Decision.Reasons)
|
recID, err := w.store.CreateRecognition(ctx, rec, res.Decision.Reasons)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.log.Error("recognize: persist", "download_id", id, "err", err)
|
log.Error("recognition persist failed", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Кандидаты базы — для ручного выбора в review.
|
// Кандидаты базы — для ручного выбора в review.
|
||||||
if cands := toStoreCandidates(recID, res.Candidates); len(cands) > 0 {
|
if cands := toStoreCandidates(recID, res.Candidates); len(cands) > 0 {
|
||||||
if err := w.store.CreateCandidates(ctx, cands); err != nil {
|
if err := w.store.CreateCandidates(ctx, cands); err != nil {
|
||||||
w.log.Warn("recognize: persist candidates", "download_id", id, "err", err)
|
log.Warn("recognition persist candidates failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,10 +189,10 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
|
|||||||
forceReview := overrides[ovrForceReview] == "1"
|
forceReview := overrides[ovrForceReview] == "1"
|
||||||
if res.Decision.Auto && !forceReview && w.layouter != nil {
|
if res.Decision.Auto && !forceReview && w.layouter != nil {
|
||||||
plan := applyOverrides(res.Plan, overrides)
|
plan := applyOverrides(res.Plan, overrides)
|
||||||
w.transition(ctx, *d, store.StateLinking, "", "")
|
lctx := w.scoped(ctx, capFileLayout, id, d.Infohash.String)
|
||||||
if err := w.linkPlan(ctx, d, plan, tag, savePath); err != nil {
|
w.transition(lctx, *d, store.StateLinking, "", "")
|
||||||
w.log.Warn("recognize: auto-apply failed, left for review",
|
if err := w.linkPlan(lctx, d, plan, tag, savePath); err != nil {
|
||||||
"download_id", id, "err", err)
|
logctx.From(lctx).Warn("auto-apply failed, left for review", "error", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -200,7 +203,7 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
|
|||||||
func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string {
|
func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string {
|
||||||
o, err := w.store.ListOverrides(ctx, id)
|
o, err := w.store.ListOverrides(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.log.Warn("recognize: list overrides", "download_id", id, "err", err)
|
logctx.From(ctx).Warn("recognition list overrides failed", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return o
|
return o
|
||||||
@@ -222,16 +225,20 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
|
|||||||
return fmt.Errorf("apply: %w", err)
|
return fmt.Errorf("apply: %w", err)
|
||||||
}
|
}
|
||||||
if d.State != store.StateReview && d.State != store.StateDeferred {
|
if d.State != store.StateReview && d.State != store.StateDeferred {
|
||||||
return fmt.Errorf("apply: download %d is in state %s (expected review/deferred)", id, d.State)
|
return fmt.Errorf("apply: download %d is in state %s (expected review/deferred): %w", id, d.State, ErrConflict)
|
||||||
}
|
}
|
||||||
|
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
|
||||||
|
|
||||||
plan, tag, err := w.effectivePlan(ctx, id)
|
plan, tag, err := w.effectivePlan(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("apply: %w", err)
|
return fmt.Errorf("apply: %w", err)
|
||||||
}
|
}
|
||||||
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
||||||
if err != nil || !ok {
|
if err != nil {
|
||||||
return fmt.Errorf("apply: torrent not found: %v", err)
|
return fmt.Errorf("apply: lookup torrent: %w", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("apply: torrent not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
w.transition(ctx, *d, store.StateLinking, "", "")
|
w.transition(ctx, *d, store.StateLinking, "", "")
|
||||||
@@ -282,7 +289,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.transition(ctx, *d, store.StateDone, "", "")
|
w.transition(ctx, *d, store.StateDone, "", "")
|
||||||
w.log.Info("apply: linked", "download_id", d.ID, "batch", batch, "links", len(fl))
|
logctx.From(ctx).Info("layout linked", "batch", batch, "links", len(fl))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +307,7 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
|
|||||||
return fmt.Errorf("relink: %w", err)
|
return fmt.Errorf("relink: %w", err)
|
||||||
}
|
}
|
||||||
if d.State != store.StateReverted && d.State != store.StateCancelled {
|
if d.State != store.StateReverted && d.State != store.StateCancelled {
|
||||||
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled)", id, d.State)
|
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled): %w", id, d.State, ErrConflict)
|
||||||
}
|
}
|
||||||
if !d.Infohash.Valid {
|
if !d.Infohash.Valid {
|
||||||
return fmt.Errorf("relink: download %d has no infohash", id)
|
return fmt.Errorf("relink: download %d has no infohash", id)
|
||||||
@@ -324,8 +331,9 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
|
|||||||
if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil {
|
if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil {
|
||||||
return fmt.Errorf("relink: %w", err)
|
return fmt.Errorf("relink: %w", err)
|
||||||
}
|
}
|
||||||
|
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||||
w.log.Info("relink: re-recognizing download", "download_id", id, "from", d.State)
|
logctx.From(ctx).Info("relink re-recognizing", "from", d.State)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +348,8 @@ func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.log.Info("review: re-recognizing without hint", "download_id", id)
|
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||||
|
logctx.From(ctx).Info("review re-recognizing without hint")
|
||||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -358,10 +367,11 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||||
if err := w.store.AddHint(ctx, id, hint); err != nil {
|
if err := w.store.AddHint(ctx, id, hint); err != nil {
|
||||||
return fmt.Errorf("refine: %w", err)
|
return fmt.Errorf("refine: %w", err)
|
||||||
}
|
}
|
||||||
w.log.Info("review: hint added, re-recognizing", "download_id", id, "hint", hint)
|
logctx.From(ctx).Info("review hint added", "hint", hint)
|
||||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -379,6 +389,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||||
if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil {
|
if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil {
|
||||||
return fmt.Errorf("set type: %w", err)
|
return fmt.Errorf("set type: %w", err)
|
||||||
}
|
}
|
||||||
@@ -403,7 +414,8 @@ func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
|
|||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
if _, err := w.requireReviewable(ctx, id, "ignore"); err != nil {
|
d, err := w.requireReviewable(ctx, id, "ignore")
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
overrides, err := w.store.ListOverrides(ctx, id)
|
overrides, err := w.store.ListOverrides(ctx, id)
|
||||||
@@ -418,7 +430,7 @@ func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
|
|||||||
if err := w.store.SetOverride(ctx, id, ovrIgnoredFiles, string(b)); err != nil {
|
if err := w.store.SetOverride(ctx, id, ovrIgnoredFiles, string(b)); err != nil {
|
||||||
return fmt.Errorf("ignore: %w", err)
|
return fmt.Errorf("ignore: %w", err)
|
||||||
}
|
}
|
||||||
w.log.Info("review: file ignored", "download_id", id, "src", src)
|
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review file ignored", "src", src)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +446,7 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
|
|||||||
if d.State.IsTerminal() {
|
if d.State.IsTerminal() {
|
||||||
return fmt.Errorf("defer: download %d is terminal (%s)", id, d.State)
|
return fmt.Errorf("defer: download %d is terminal (%s)", id, d.State)
|
||||||
}
|
}
|
||||||
|
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||||
w.transition(ctx, *d, store.StateDeferred, "", "")
|
w.transition(ctx, *d, store.StateDeferred, "", "")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -452,8 +465,9 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
|
|||||||
return fmt.Errorf("undo: %w", err)
|
return fmt.Errorf("undo: %w", err)
|
||||||
}
|
}
|
||||||
if d.State != store.StateDone {
|
if d.State != store.StateDone {
|
||||||
return fmt.Errorf("undo: download %d is in state %s (expected done)", id, d.State)
|
return fmt.Errorf("undo: download %d is in state %s (expected done): %w", id, d.State, ErrConflict)
|
||||||
}
|
}
|
||||||
|
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
|
||||||
batch, err := w.store.LatestBatchID(ctx, id)
|
batch, err := w.store.LatestBatchID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("undo: %w", err)
|
return fmt.Errorf("undo: %w", err)
|
||||||
@@ -477,7 +491,7 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
|
|||||||
return fmt.Errorf("undo: %w", err)
|
return fmt.Errorf("undo: %w", err)
|
||||||
}
|
}
|
||||||
w.transition(ctx, *d, store.StateReverted, "", "")
|
w.transition(ctx, *d, store.StateReverted, "", "")
|
||||||
w.log.Info("undo: reverted", "download_id", id, "batch", batch, "removed", n)
|
logctx.From(ctx).Info("layout reverted", "batch", batch, "removed", n)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,7 +502,7 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
|
|||||||
return nil, fmt.Errorf("%s: %w", op, err)
|
return nil, fmt.Errorf("%s: %w", op, err)
|
||||||
}
|
}
|
||||||
if d.State != store.StateReview && d.State != store.StateDeferred {
|
if d.State != store.StateReview && d.State != store.StateDeferred {
|
||||||
return nil, fmt.Errorf("%s: download %d is in state %s (expected review/deferred)", op, id, d.State)
|
return nil, fmt.Errorf("%s: download %d is in state %s (expected review/deferred): %w", op, id, d.State, ErrConflict)
|
||||||
}
|
}
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
@@ -502,7 +516,8 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) err
|
|||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
if _, err := w.requireReviewable(ctx, id, "choose candidate"); err != nil {
|
d, err := w.requireReviewable(ctx, id, "choose candidate")
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||||
@@ -532,8 +547,8 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) err
|
|||||||
if err := w.store.SetCandidateChosen(ctx, rec.ID, candidateID); err != nil {
|
if err := w.store.SetCandidateChosen(ctx, rec.ID, candidateID); err != nil {
|
||||||
return fmt.Errorf("choose candidate: %w", err)
|
return fmt.Errorf("choose candidate: %w", err)
|
||||||
}
|
}
|
||||||
w.log.Info("review: candidate chosen",
|
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review candidate chosen",
|
||||||
"download_id", id, "provider", cand.Provider, "provider_id", cand.ProviderID)
|
"provider", cand.Provider, "provider_id", cand.ProviderID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,7 +567,8 @@ func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, provider
|
|||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
if _, err := w.requireReviewable(ctx, id, "set provider"); err != nil {
|
d, err := w.requireReviewable(ctx, id, "set provider")
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := w.store.SetOverride(ctx, id, ovrProvider, provider); err != nil {
|
if err := w.store.SetOverride(ctx, id, ovrProvider, provider); err != nil {
|
||||||
@@ -561,8 +577,8 @@ func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, provider
|
|||||||
if err := w.store.SetOverride(ctx, id, ovrProviderID, providerID); err != nil {
|
if err := w.store.SetOverride(ctx, id, ovrProviderID, providerID); err != nil {
|
||||||
return fmt.Errorf("set provider: %w", err)
|
return fmt.Errorf("set provider: %w", err)
|
||||||
}
|
}
|
||||||
w.log.Info("review: provider set manually",
|
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider set",
|
||||||
"download_id", id, "provider", provider, "provider_id", providerID)
|
"provider", provider, "provider_id", providerID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,7 +587,8 @@ func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
|
|||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
if _, err := w.requireReviewable(ctx, id, "clear provider"); err != nil {
|
d, err := w.requireReviewable(ctx, id, "clear provider")
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := w.store.SetOverride(ctx, id, ovrProvider, "none"); err != nil {
|
if err := w.store.SetOverride(ctx, id, ovrProvider, "none"); err != nil {
|
||||||
@@ -580,7 +597,7 @@ func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
|
|||||||
if err := w.store.SetOverride(ctx, id, ovrProviderID, ""); err != nil {
|
if err := w.store.SetOverride(ctx, id, ovrProviderID, ""); err != nil {
|
||||||
return fmt.Errorf("clear provider: %w", err)
|
return fmt.Errorf("clear provider: %w", err)
|
||||||
}
|
}
|
||||||
w.log.Info("review: provider cleared (no metadata base)", "download_id", id)
|
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider cleared")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,6 +622,7 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("review data: %w", err)
|
return nil, fmt.Errorf("review data: %w", err)
|
||||||
}
|
}
|
||||||
|
log := logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String))
|
||||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("review data: %w", err)
|
return nil, fmt.Errorf("review data: %w", err)
|
||||||
@@ -627,14 +645,13 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
|
|||||||
if cands, cerr := w.store.ListCandidatesByRecognition(ctx, rec.ID); cerr == nil {
|
if cands, cerr := w.store.ListCandidatesByRecognition(ctx, rec.ID); cerr == nil {
|
||||||
rd.Candidates = cands
|
rd.Candidates = cands
|
||||||
} else {
|
} else {
|
||||||
w.log.Debug("review data: list candidates failed (skipped)",
|
log.Debug("review data list candidates failed", "error", cerr)
|
||||||
"download_id", id, "err", cerr)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if rec != nil && rec.Plan.Valid {
|
if rec != nil && rec.Plan.Valid {
|
||||||
var plan recognize.Plan
|
var plan recognize.Plan
|
||||||
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
|
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
|
||||||
w.log.Warn("review data: unmarshal plan failed", "download_id", id, "err", err)
|
log.Warn("review data unmarshal plan failed", "error", err)
|
||||||
} else {
|
} else {
|
||||||
plan = applyOverrides(plan, overrides)
|
plan = applyOverrides(plan, overrides)
|
||||||
rd.Plan = plan
|
rd.Plan = plan
|
||||||
@@ -645,8 +662,7 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
|
|||||||
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil {
|
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil {
|
||||||
rd.Preview = links
|
rd.Preview = links
|
||||||
} else {
|
} else {
|
||||||
w.log.Debug("review data: build preview failed (skipped)",
|
log.Debug("review data build preview failed", "error", lerr)
|
||||||
"download_id", id, "err", lerr)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-15
@@ -20,11 +20,21 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Стадии (capability) — адресуют запись к подсистеме при корреляции по
|
||||||
|
// download_id. Уровень логов от стадии не зависит.
|
||||||
|
const (
|
||||||
|
capIngest = "ingest" // приём/скачивание (поллинг, reconcile, переходы)
|
||||||
|
capRecognize = "recognition" // распознавание фильма/сериала
|
||||||
|
capFileLayout = "file-layout" // раскладка хардлинками
|
||||||
|
capReview = "review" // ручные команды ревью
|
||||||
|
)
|
||||||
|
|
||||||
// Store — нужная worker часть хранилища.
|
// Store — нужная worker часть хранилища.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error)
|
ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error)
|
||||||
@@ -147,6 +157,17 @@ func defaultBatchID() string {
|
|||||||
return fmt.Sprintf("b-%d", time.Now().UnixNano())
|
return fmt.Sprintf("b-%d", time.Now().UnixNano())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scoped кладёт в ctx scoped-логгер загрузки (capability + download_id
|
||||||
|
// [+ infohash]); стадии и внешние клиенты достают его из ctx и дописывают эти
|
||||||
|
// ключи на каждую запись сами — без ручного доклеивания download_id.
|
||||||
|
func (w *Worker) scoped(ctx context.Context, capability string, id int64, infohash string) context.Context {
|
||||||
|
log := w.log.With("capability", capability, "download_id", id)
|
||||||
|
if infohash != "" {
|
||||||
|
log = log.With("infohash", infohash)
|
||||||
|
}
|
||||||
|
return logctx.With(ctx, log)
|
||||||
|
}
|
||||||
|
|
||||||
// Run крутит цикл поллинга до отмены ctx.
|
// Run крутит цикл поллинга до отмены ctx.
|
||||||
func (w *Worker) Run(ctx context.Context) {
|
func (w *Worker) Run(ctx context.Context) {
|
||||||
w.log.Info("worker started", "poll_interval", w.cfg.PollInterval, "category", w.cfg.Category)
|
w.log.Info("worker started", "poll_interval", w.cfg.PollInterval, "category", w.cfg.Category)
|
||||||
@@ -167,7 +188,7 @@ func (w *Worker) Run(ctx context.Context) {
|
|||||||
|
|
||||||
func (w *Worker) pollOnce(ctx context.Context) {
|
func (w *Worker) pollOnce(ctx context.Context) {
|
||||||
if err := w.Poll(ctx); err != nil {
|
if err := w.Poll(ctx); err != nil {
|
||||||
w.log.Warn("poll failed", "err", err)
|
w.log.Warn("poll failed", "error", err)
|
||||||
}
|
}
|
||||||
// Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке).
|
// Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке).
|
||||||
if w.recognizer != nil {
|
if w.recognizer != nil {
|
||||||
@@ -209,7 +230,7 @@ func (w *Worker) Poll(ctx context.Context) error {
|
|||||||
t, ok := byHash[strings.ToLower(d.Infohash.String)]
|
t, ok := byHash[strings.ToLower(d.Infohash.String)]
|
||||||
if !ok {
|
if !ok {
|
||||||
w.log.Warn("active download not found in qbittorrent",
|
w.log.Warn("active download not found in qbittorrent",
|
||||||
"download_id", d.ID, "infohash", d.Infohash.String)
|
"capability", capIngest, "download_id", d.ID, "infohash", d.Infohash.String)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
w.reconcile(ctx, d, t)
|
w.reconcile(ctx, d, t)
|
||||||
@@ -220,6 +241,7 @@ func (w *Worker) Poll(ctx context.Context) error {
|
|||||||
// reconcile двигает одну задачу по состоянию её торрента. Вызывается под
|
// reconcile двигает одну задачу по состоянию её торрента. Вызывается под
|
||||||
// w.mu.
|
// w.mu.
|
||||||
func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent) {
|
func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent) {
|
||||||
|
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
|
||||||
switch classify(t.State) {
|
switch classify(t.State) {
|
||||||
case classReady:
|
case classReady:
|
||||||
w.transition(ctx, d, store.StateCompleted, "", "")
|
w.transition(ctx, d, store.StateCompleted, "", "")
|
||||||
@@ -237,7 +259,7 @@ func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent)
|
|||||||
func (w *Worker) checkTimeouts(ctx context.Context, d store.Download, t qbt.Torrent) {
|
func (w *Worker) checkTimeouts(ctx context.Context, d store.Download, t qbt.Torrent) {
|
||||||
created, err := d.CreatedTime()
|
created, err := d.CreatedTime()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.log.Warn("cannot parse created_at", "download_id", d.ID, "value", d.CreatedAt, "err", err)
|
logctx.From(ctx).Warn("cannot parse created_at", "value", d.CreatedAt, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
age := w.now().Sub(created)
|
age := w.now().Sub(created)
|
||||||
@@ -254,13 +276,14 @@ func (w *Worker) checkTimeouts(ctx context.Context, d store.Download, t qbt.Torr
|
|||||||
|
|
||||||
// transition пишет новое состояние и логирует переход.
|
// transition пишет новое состояние и логирует переход.
|
||||||
func (w *Worker) transition(ctx context.Context, d store.Download, state store.State, code, msg string) {
|
func (w *Worker) transition(ctx context.Context, d store.Download, state store.State, code, msg string) {
|
||||||
|
// 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 {
|
if err := w.store.SetDownloadState(ctx, d.ID, state, code, msg); err != nil {
|
||||||
w.log.Error("state transition failed",
|
log.Error("state transition failed", "from", d.State, "to", state, "error", err)
|
||||||
"download_id", d.ID, "from", d.State, "to", state, "err", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.log.Info("state transition",
|
log.Info("state transition", "from", d.State, "to", state, "code", code)
|
||||||
"download_id", d.ID, "from", d.State, "to", state, "code", code)
|
|
||||||
|
|
||||||
// Пинги — неблокирующе и в отдельном контексте: вызов уходит в сеть, а
|
// Пинги — неблокирующе и в отдельном контексте: вызов уходит в сеть, а
|
||||||
// мы под w.mu (Notify читает состояние уже после освобождения замка).
|
// мы под w.mu (Notify читает состояние уже после освобождения замка).
|
||||||
@@ -277,12 +300,11 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
|
|||||||
// новые файлы быстрее появились в проигрывателе. Тоже неблокирующе и вне
|
// новые файлы быстрее появились в проигрывателе. Тоже неблокирующе и вне
|
||||||
// w.mu; недоступность Jellyfin не влияет на состояние задачи.
|
// w.mu; недоступность Jellyfin не влияет на состояние задачи.
|
||||||
if w.scanner != nil && state == store.StateDone {
|
if w.scanner != nil && state == store.StateDone {
|
||||||
id := d.ID
|
// Скан Jellyfin — неблокирующе и вне w.mu, в фоновом ctx со scoped-логгером
|
||||||
go func() {
|
// (download_id для корреляции ext.*-записи клиента). Недоступность Jellyfin
|
||||||
if err := w.scanner.RefreshLibraries(context.Background()); err != nil {
|
// на задачу не влияет; ошибку вызова логирует сам клиент (ext.*), здесь гасим.
|
||||||
w.log.Warn("jellyfin: library refresh failed", "download_id", id, "err", err)
|
gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.Infohash.String)
|
||||||
}
|
go func() { _ = w.scanner.RefreshLibraries(gctx) }()
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +324,7 @@ func (w *Worker) Cancel(ctx context.Context, id int64) error {
|
|||||||
if err := w.store.SetDownloadState(ctx, id, store.StateCancelled, "", ""); err != nil {
|
if err := w.store.SetDownloadState(ctx, id, store.StateCancelled, "", ""); err != nil {
|
||||||
return fmt.Errorf("cancel: %w", err)
|
return fmt.Errorf("cancel: %w", err)
|
||||||
}
|
}
|
||||||
w.log.Info("download cancelled", "download_id", id, "from", d.State)
|
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("download cancelled", "from", d.State)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +353,7 @@ func (w *Worker) Retry(ctx context.Context, id int64) error {
|
|||||||
if err := w.store.SetDownloadState(ctx, id, store.StateDownloading, "", ""); err != nil {
|
if err := w.store.SetDownloadState(ctx, id, store.StateDownloading, "", ""); err != nil {
|
||||||
return fmt.Errorf("retry: %w", err)
|
return fmt.Errorf("retry: %w", err)
|
||||||
}
|
}
|
||||||
w.log.Info("download retried", "download_id", id, "from", d.State)
|
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("download retried", "from", d.State)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,21 @@ context: |
|
|||||||
apply, до archive).
|
apply, до archive).
|
||||||
- Тривиальная задача — достаточно одного прохода (код).
|
- Тривиальная задача — достаточно одного прохода (код).
|
||||||
|
|
||||||
|
Конвенции кода (соблюдать при apply):
|
||||||
|
- Логирование — только log/slog (структурированный JSON), без fmt.Println.
|
||||||
|
Логируем все вызовы внешних сервисов; healthcheck-эндпоинты — на DEBUG.
|
||||||
|
Детали: уровни, обязательные поля — docs/conventions/logging.md.
|
||||||
|
- Безопасность: никаких секретов в полях логов (пароли qBittorrent,
|
||||||
|
API-ключи LLM/метабаз, auth-заголовки).
|
||||||
|
- Конфигурация — только TOML; секреты рендерит деплой (Ansible+Vault) в
|
||||||
|
файл (config.toml не коммитится, 0600), env для конфига не используем;
|
||||||
|
валидация на старте. Детали: docs/conventions/config.md.
|
||||||
|
- Ошибки — stdlib, обёртка с контекстом (fmt.Errorf("...: %w", err)),
|
||||||
|
проверка errors.Is/errors.As, трансляция доменной ошибки в ответ на
|
||||||
|
внешней границе (наружу не отдаём текст внутренней ошибки). Детали:
|
||||||
|
docs/conventions/errors.md.
|
||||||
|
- Время — всегда с явным TZ (сервер в Europe/Moscow; логи — в UTC).
|
||||||
|
|
||||||
# Project context (optional)
|
# Project context (optional)
|
||||||
# This is shown to AI when creating artifacts.
|
# This is shown to AI when creating artifacts.
|
||||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||||
|
|||||||
Reference in New Issue
Block a user