diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index a55f2f4..7b01abd 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -80,13 +80,13 @@ func runServe(args []string) error { } // Вывод отображаемого имени торрента из контекста (best-effort). Без LLM - // работает только алгоритмический фолбек. + // работает только алгоритмический фолбек. Namer зовёт worker на шаге + // добавления пойманной загрузки (не синхронный приём). namer := naming.New(llmProvider, cfg.LLM.MaxRetries, logger) - ingestor := ingest.New(st, qb, namer, ingest.Config{ - Category: cfg.QBittorrent.Category, - SavePath: cfg.QBittorrent.SavePath, - }, logger) + // Быстрый приём: сохраняет загрузку в catched и сразу отвечает; добавление в + // qBittorrent и вывод имени делает worker (см. download-tracking). + ingestor := ingest.New(st, logger) // Ф4: базы метаданных (опц.). Без них авто-раскладки нет — всё в review. providers, err := metadataProviders(cfg, logger) @@ -126,8 +126,11 @@ func runServe(args []string) error { PollInterval: cfg.Worker.PollInterval.Std(), StuckAfter: cfg.Worker.StuckAfter.Std(), MagnetTimeout: cfg.Worker.MagnetTimeout.Std(), + CatchTimeout: cfg.Worker.CatchTimeout.Std(), SourceMissingThreshold: cfg.Worker.SourceMissingThreshold, }, logger) + // Вывод имени на шаге добавления пойманной загрузки (best-effort). + wrk.SetNamer(namer) // Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin // не валит сервис — скан просто не сработает (залогируется в воркере). @@ -188,10 +191,6 @@ func runServe(args []string) error { WebBaseURL: cfg.Telegram.WebBaseURL, }, logger) wrk.SetNotifier(bot) - // Приёмные падения (qbit_add) минуют worker — уведомляем напрямую. - ingestor.SetFailureNotifier(func(id string) { - bot.Notify(context.Background(), id, worker.EventFailed) - }) go bot.Run(ctx) logger.Info("telegram bot enabled", "bot", api.Self.UserName, "allowed_users", len(cfg.Telegram.AllowedUserIDs)) diff --git a/config.example.toml b/config.example.toml index 2399386..a60f565 100644 --- a/config.example.toml +++ b/config.example.toml @@ -67,6 +67,7 @@ timeout = "10s" # таймаут запроса к Jellyfin poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h) stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration magnet_timeout = "24h" # страховочный предел ожидания метаданных magnet (не рабочий механизм: ожившие задачи воскрешаются сверкой); Go-duration +catch_timeout = "10m" # страховочный предел: пойманная (catched) задача не добавилась в qBittorrent за это время → failed; Go-duration source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс) [recognition] diff --git a/internal/config/config.go b/internal/config/config.go index 20da638..07fed04 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -111,6 +111,10 @@ type Worker struct { PollInterval Duration `toml:"poll_interval"` StuckAfter Duration `toml:"stuck_after"` MagnetTimeout Duration `toml:"magnet_timeout"` + // CatchTimeout — сколько пойманная (catched) загрузка может ждать добавления + // в qBittorrent, прежде чем счесть его невозможным и увести задачу в failed. + // Редкий предохранитель на случай устойчивой недоступности qBittorrent. + CatchTimeout Duration `toml:"catch_timeout"` // SourceMissingThreshold — сколько подряд тиков сверки без раздачи в // qBittorrent нужно, чтобы счесть источник удалённым (дебаунс пропажи, // см. state-reconciliation). Любое появление раздачи сбрасывает счётчик. @@ -208,6 +212,7 @@ func Default() *Config { PollInterval: Duration(5 * time.Second), StuckAfter: Duration(time.Hour), MagnetTimeout: Duration(24 * time.Hour), + CatchTimeout: Duration(10 * time.Minute), SourceMissingThreshold: 3, }, Recognition: Recognition{AutoConfidenceThreshold: 0.85}, diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index a98491c..0131c27 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -20,6 +20,7 @@ type downloadDetailView struct { Infohashes []string // все хеши загрузки (блок «Информация о торренте») Context string State string + SelfPoll bool // catched → страница сама опрашивает себя до перехода Error string ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg Note string @@ -99,6 +100,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet Infohashes: d.HashList(), Context: d.Context, State: string(d.State), + SelfPoll: d.State == store.StateCatched, Error: d.ErrorMsg.String, Note: desyncNote(d.State), CreatedAt: d.CreatedAt, diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 4ba014e..23fe766 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -113,6 +113,9 @@ func NewRouter(d Deps) (http.Handler, error) { // Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера). r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress) r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding) + // Карточка целиком: самополлинг catched до перехода в downloading (бейдж, + // имя и появившийся прогресс обновляются без перезагрузки). + r.Get("/fragments/downloads/{id}/card", s.handleFragCard) // Тело ревью для поллинга recognizing (htmx-своп до готового плана). r.Get("/fragments/downloads/{id}/review", s.handleFragReview) r.Post("/ui/downloads", s.handleUIAdd) @@ -195,6 +198,7 @@ type downloadView struct { Error string Terminal bool IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг + SelfPoll bool // catched → карточка сама опрашивает себя до перехода Progress progressView // живой прогресс (заполняется в handleIndex из снимка) Reviewable bool // review/deferred — есть экран ревью Undoable bool // done — можно откатить раскладку @@ -618,6 +622,7 @@ func (s *server) toView(d store.Download, now time.Time) downloadView { Error: d.ErrorMsg.String, Terminal: d.State.IsTerminal(), IsDownloading: d.State == store.StateDownloading, + SelfPoll: d.State == store.StateCatched, Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, Undoable: d.State == store.StateDone, Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled || diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 2df62e4..9b8a26d 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -246,6 +246,33 @@ func TestIndexRenders(t *testing.T) { } } +// Карточка catched несёт самозавершающийся htmx-поллинг (обновится без +// перезагрузки при переходе в downloading) и человекочитаемый бейдж. +func TestCatchedCardSelfPolls(t *testing.T) { + reader := &fakeReader{get: &store.Download{ + ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", + State: store.StateCatched, + }} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader}) + + resp, err := http.Get(srv.URL + "/fragments/downloads/" + tid + "/card") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + s := string(body) + if !strings.Contains(s, `hx-get="/fragments/downloads/`+tid+`/card"`) { + t.Errorf("карточка catched без самополлинга: %s", s) + } + if !strings.Contains(s, "принято, добавляется") { + t.Errorf("нет подписи бейджа catched: %s", s) + } +} + type ingestErr string func (e ingestErr) Error() string { return string(e) } diff --git a/internal/httpapi/live.go b/internal/httpapi/live.go index f7e247a..2de0a93 100644 --- a/internal/httpapi/live.go +++ b/internal/httpapi/live.go @@ -96,6 +96,26 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) { s.render(w, "progress", buildProgress(id, active, l, ok)) } +// handleFragCard отдаёт карточку списка целиком (htmx-самополлинг catched): +// пока загрузка в catched, карточка опрашивает себя и по переходе в downloading +// приносит обновлённый бейдж/имя и прогресс-поллер; выйдя из catched, свежая +// карточка уже не несёт самополлинга — цикл завершается сам. +func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + http.Error(w, "не найдено", http.StatusNotFound) + return + } + d, err := s.deps.Reader.GetDownload(r.Context(), id) + if err != nil { + s.fragErr(w, err, id) + return + } + // layoutSize 0: у catched раскладки нет; в downloading размер берётся из + // живого снимка внутри buildCardView. + s.render(w, "card", s.buildCardView(*d, time.Now(), 0)) +} + // handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг). func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) diff --git a/internal/httpapi/render.go b/internal/httpapi/render.go index 1e76c32..373b18e 100644 --- a/internal/httpapi/render.go +++ b/internal/httpapi/render.go @@ -40,6 +40,8 @@ func staticHandler(fsys fs.FS) http.Handler { // как есть, чтобы не терять его в UI. func badgeLabel(state string) string { switch state { + case "catched": + return "🎣 принято, добавляется" case "downloading": return "⬇ качается" case "completed": diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index 8ffb8be..8a1c353 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -1,6 +1,9 @@ -// Package ingest — use-case приёма загрузки, общий для всех транспортов -// (HTTP, Telegram, CLI). Принимает источник + контекст, отдаёт источник в -// qBittorrent и заводит/находит задачу в БД. +// Package ingest — use-case быстрого приёма загрузки, общий для всех +// транспортов (HTTP, Telegram, CLI). Синхронно только парсит источник, +// синтезирует контекст из полей ссылки, дедуплицирует и сохраняет загрузку в +// состоянии `catched`, сразу возвращая ответ. Вывод отображаемого имени +// (медленный LLM) и добавление в qBittorrent — отдельный асинхронный шаг +// worker'а (см. download-tracking). package ingest import ( @@ -11,21 +14,16 @@ import ( "git.vakhrushev.me/av/jellybit/internal/logctx" "git.vakhrushev.me/av/jellybit/internal/magnet" - "git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/store" ) // capIngest — стадия приёма для поля capability в логах. const capIngest = "ingest" -// errCodeQbitAdd — error_code задачи, упавшей на добавлении источника в -// qBittorrent (раздачи в qBittorrent нет, восстановлению не подлежит). -const errCodeQbitAdd = "qbit_add" - // Store — нужная ingest часть хранилища. type Store interface { - // FindActiveByInfohash — быстрый читающий дедуп-чек (до вызова LLM-namer); - // авторитетная проверка — внутри CreateDownloadIfNoActive. + // FindActiveByInfohash — быстрый читающий дедуп-чек; авторитетная проверка — + // внутри CreateDownloadIfNoActive. FindActiveByInfohash(ctx context.Context, hashes ...string) (*store.Download, error) // CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная // загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil — @@ -34,50 +32,19 @@ type Store interface { // AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на // быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive. AddInfohashes(ctx context.Context, downloadID string, hashes []string) error - SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error } -// QBittorrent — нужная ingest часть клиента qBittorrent. -type QBittorrent interface { - Add(ctx context.Context, ar qbt.AddRequest) error -} - -// Namer выводит человекочитаемое отображаемое имя торрента из контекста. -// Пустой результат → имя в qBittorrent не задаём. nil → шаг пропускается. -type Namer interface { - DeriveName(ctx context.Context, contextText, hint string) string -} - -// Config — параметры добавления в qBittorrent. -type Config struct { - Category string - SavePath string -} - -// Service — реализация приёма. +// Service — реализация быстрого приёма. type Service struct { store Store - qbt QBittorrent - namer Namer - cfg Config log *slog.Logger - - // notifyFailed — опц. пинг автору о падении приёма (добавление в qBittorrent - // не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не - // попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest - // сам; closure избавляет ядро приёма от зависимости на пакет worker. - notifyFailed func(downloadID string) } -// New собирает сервис приёма. namer опционален (nil → отображаемое имя не -// выводится; qBittorrent оставит своё). -func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *Service { - return &Service{store: st, qbt: qb, namer: namer, cfg: cfg, log: log} +// New собирает сервис приёма. +func New(st Store, log *slog.Logger) *Service { + return &Service{store: st, log: log} } -// SetFailureNotifier подключает пинг о падении приёма (до начала работы). -func (s *Service) SetFailureNotifier(fn func(downloadID string)) { s.notifyFailed = fn } - // Request — входной запрос приёма. type Request struct { Source string // пока — magnet-ссылка @@ -92,8 +59,10 @@ type Result struct { Deduplicated bool // присоединились к уже активной задаче, нового добавления не было } -// Ingest принимает источник: извлекает infohash, дедуплицирует по активной -// задаче, иначе заводит задачу и отдаёт источник в qBittorrent. +// Ingest быстро принимает источник: извлекает infohash, синтезирует контекст из +// полей ссылки, дедуплицирует по активной задаче, иначе сохраняет загрузку в +// `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод +// имени выполняет worker (см. download-tracking). func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { source := strings.TrimSpace(req.Source) info, err := magnet.Parse(source) @@ -103,14 +72,12 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { } // Scoped-логгер стадии приёма: download_id допишется после CreateDownload. - // Кладём в ctx, чтобы внешние клиенты (qBittorrent, LLM-namer) дописывали - // ключи корреляции к своим ext.*-записям сами. log := s.log.With("capability", capIngest, "infohash", info.Infohash) ctx = logctx.With(ctx, log) - // Быстрый дедуп-чек до дорогого LLM-namer; авторитетная (атомарная) - // проверка — внутри CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из - // хешей источника: гибридный magnet несёт и v1, и v2. + // Быстрый дедуп-чек; авторитетная (атомарная) проверка — внутри + // CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника: + // гибридный magnet несёт и v1, и v2. if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohashes...); err != nil { return Result{}, fmt.Errorf("ingest: lookup active: %w", err) } else if existing != nil { @@ -118,26 +85,15 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { return s.attached(ctx, info, existing), nil } - // Отображаемое имя для списка qBit — best-effort: не валит приём. - // Выводится синхронно (param rename действует только при добавлении) и - // ДО CreateDownload, чтобы возможный медленный вызов LLM не расширял окно - // «строка в БД есть, в qBittorrent ещё нет». Имя от строки БД не зависит. - // Namer получает СЫРОЙ req.Context (+ dn-hint), не обогащённый: строки-факты - // синтеза (Размер:/Трекер:) не должны становиться отображаемым именем. - var rename string - if s.namer != nil { - rename = s.namer.DeriveName(ctx, req.Context, info.DisplayName) - } - // Контекст распознавания дополняем фактами из полей самой magnet-ссылки - // (dn/xl/tr/xs/kt) — без сети. Пользовательский текст идёт первым. Результат - // уходит только в download.Context (его читают recognition и веб-UI). + // (dn/xl/tr/xs/kt) — без сети. Пользовательский текст идёт первым. + // DisplayName пуст: имя выведет worker на шаге добавления (rename действует + // только при добавлении, а тут медленный LLM в пути ответа недопустим). d := &store.Download{ - SourceType: store.SourceMagnet, - SourceRef: source, - DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI - Context: mergeContext(req.Context, info.Context()), - State: store.StateDownloading, + SourceType: store.SourceMagnet, + SourceRef: source, + Context: mergeContext(req.Context, info.Context()), + State: store.StateCatched, } // Все хеши из magnet (гибридный несёт v1 и v2); kind store выведет по длине. existing, err := s.store.CreateDownloadIfNoActive(ctx, d, info.Infohashes) @@ -156,38 +112,12 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { Deduplicated: true, }, nil } - id := d.ID - log = log.With("download_id", id) - ctx = logctx.With(ctx, log) - addErr := s.qbt.Add(ctx, qbt.AddRequest{ - URLs: []string{source}, - Category: s.cfg.Category, - SavePath: s.cfg.SavePath, - Rename: rename, - }) - if addErr != nil { - // Граница доменной операции приёма: логируем исход один раз (ERROR). - // Поведение самого вызова qBittorrent уже залогировал клиент (ext.*) — - // это разные факты, не дубль. - log.Error("download accept failed", "error", addErr) - // Задача уже в БД — помечаем failed, чтобы worker её не подхватил. - if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, errCodeQbitAdd, addErr.Error()); setErr != nil { - log.Error("mark download failed after qbit error failed", "error", setErr) - } else if s.notifyFailed != nil { - // Это падение минует worker.transition (раздачи в qBit нет) — уведомляем - // сами, чтобы приёмные провалы тоже доходили до автора. - go s.notifyFailed(id) - } - return Result{DownloadID: id, Infohashes: info.Infohashes, State: store.StateFailed}, - fmt.Errorf("ingest: add to qbittorrent: %w", addErr) - } - - log.Info("download accepted", "category", s.cfg.Category) + log.Info("download catched", "download_id", d.ID) return Result{ - DownloadID: id, + DownloadID: d.ID, Infohashes: info.Infohashes, - State: store.StateDownloading, + State: store.StateCatched, }, nil } diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index 9bc8b77..384b507 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -2,15 +2,12 @@ package ingest import ( "context" - "errors" "io" "log/slog" "strings" "testing" - "time" "git.vakhrushev.me/av/jellybit/internal/ident" - "git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/store" ) @@ -19,18 +16,10 @@ const sampleMagnet = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331 const sampleInfohash = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" type fakeStore struct { - active *store.Download - created []store.Download - hashes [][]string - toppedUp []string - stateCalls []stateCall -} - -type stateCall struct { - id string - state store.State - code string - msg string + active *store.Download + created []store.Download + hashes [][]string + toppedUp []string } func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store.Download, error) { @@ -53,123 +42,101 @@ func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string) return nil } -func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error { - f.stateCalls = append(f.stateCalls, stateCall{id, st, code, msg}) - return nil +func newService(st Store) *Service { + return New(st, slog.New(slog.NewTextHandler(io.Discard, nil))) } -type fakeQbt struct { - added []qbt.AddRequest - err error -} - -func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error { - if f.err != nil { - return f.err - } - f.added = append(f.added, ar) - return nil -} - -// fakeNamer возвращает заранее заданное имя; фиксирует переданные аргументы. -type fakeNamer struct { - name string - gotContext string - gotHint string - called bool -} - -func (f *fakeNamer) DeriveName(_ context.Context, contextText, hint string) string { - f.called = true - f.gotContext = contextText - f.gotHint = hint - return f.name -} - -func newService(st Store, qb QBittorrent) *Service { - return newServiceWithNamer(st, qb, nil) -} - -func newServiceWithNamer(st Store, qb QBittorrent, nm Namer) *Service { - return New(st, qb, nm, Config{Category: "jellybit", SavePath: "/srv/media/downloads"}, - slog.New(slog.NewTextHandler(io.Discard, nil))) -} - -func TestIngestHappyPath(t *testing.T) { +// Быстрый приём: сохраняем загрузку в catched и сразу отвечаем; qBittorrent и +// вывод имени в пути приёма не участвуют (это делает worker). +func TestIngestCatchesFast(t *testing.T) { fs := &fakeStore{} - fq := &fakeQbt{} - res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet, Context: "Дюна 2"}) + res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet, Context: "Дюна 2"}) if err != nil { t.Fatalf("Ingest: %v", err) } if len(res.Infohashes) != 1 || res.Infohashes[0] != sampleInfohash { t.Errorf("infohashes = %v", res.Infohashes) } - if res.State != store.StateDownloading || res.Deduplicated { + if res.State != store.StateCatched || res.Deduplicated { t.Errorf("res = %+v", res) } if len(fs.created) != 1 { t.Fatalf("создано задач: %d, want 1", len(fs.created)) } + got := fs.created[0] + if got.State != store.StateCatched { + t.Errorf("state задачи = %q, want catched", got.State) + } + // Имя выводит worker на шаге добавления — при приёме display_name пуст. + if got.DisplayName != "" { + t.Errorf("display_name при приёме = %q, want пусто", got.DisplayName) + } // download.Context = пользовательский текст + синтез из полей magnet // (dn=Dune). Текст пользователя идёт первым. - if got := fs.created[0].Context; !strings.HasPrefix(got, "Дюна 2") || !strings.Contains(got, "Dune") { - t.Errorf("сохранённый контекст = %q", got) + if !strings.HasPrefix(got.Context, "Дюна 2") || !strings.Contains(got.Context, "Dune") { + t.Errorf("сохранённый контекст = %q", got.Context) } if len(fs.hashes) != 1 || len(fs.hashes[0]) != 1 || fs.hashes[0][0] != sampleInfohash { t.Errorf("хеши задачи: %v", fs.hashes) } - if len(fq.added) != 1 { - t.Fatalf("вызовов qbt.Add: %d, want 1", len(fq.added)) +} + +// Голый magnet без текста: download.Context синтезируется из полей ссылки +// (dn-имя + размер), приём проходит штатно. +func TestIngestMagnetOnlySynthesizesContext(t *testing.T) { + const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" + + "&dn=Dune.Part.Two.2024.2160p&xl=2200000000" + fs := &fakeStore{} + if _, err := newService(fs).Ingest(context.Background(), Request{Source: raw}); err != nil { + t.Fatalf("Ingest: %v", err) } - add := fq.added[0] - if len(add.URLs) != 1 || add.URLs[0] != sampleMagnet { - t.Errorf("URLs = %v", add.URLs) + if len(fs.created) != 1 { + t.Fatalf("создано задач: %d, want 1", len(fs.created)) } - if add.Category != "jellybit" || add.SavePath != "/srv/media/downloads" { - t.Errorf("category/savepath = %q/%q", add.Category, add.SavePath) + ctx := fs.created[0].Context + if !strings.Contains(ctx, "Dune.Part.Two.2024.2160p") || !strings.Contains(ctx, "Размер:") { + t.Errorf("контекст не синтезирован из magnet: %q", ctx) } } -func TestIngestSetsDisplayName(t *testing.T) { +// Заглушка-dn (rutracker-topic-*) как строка-название в контекст не попадает, +// но домен трекера — попадает (сигнал для recognition). +func TestIngestSynthDropsStubName(t *testing.T) { + const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" + + "&dn=rutracker-topic-6514485&tr=http%3A%2F%2Fbt.t-ru.org%2Fann%3Fmagnet" fs := &fakeStore{} - fq := &fakeQbt{} - nm := &fakeNamer{name: "Дюна: Часть вторая (2024)"} - _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(), - Request{Source: sampleMagnet, Context: "Дюна 2"}) - if err != nil { + if _, err := newService(fs).Ingest(context.Background(), Request{Source: raw}); err != nil { t.Fatalf("Ingest: %v", err) } - if !nm.called || nm.gotContext != "Дюна 2" || nm.gotHint != "Dune" { - t.Errorf("namer получил context=%q hint=%q (called=%v)", nm.gotContext, nm.gotHint, nm.called) + got := fs.created[0].Context + if !strings.Contains(got, "t-ru.org") { + t.Errorf("download.Context не обогащён доменом трекера: %q", got) } - if len(fq.added) != 1 || fq.added[0].Rename != "Дюна: Часть вторая (2024)" { - t.Errorf("rename = %q, want %q", fq.added[0].Rename, "Дюна: Часть вторая (2024)") - } - // То же имя сохраняется у загрузки — заголовок в веб-UI. - if len(fs.created) != 1 || fs.created[0].DisplayName != "Дюна: Часть вторая (2024)" { - t.Errorf("display_name = %q, want %q", fs.created[0].DisplayName, "Дюна: Часть вторая (2024)") + if strings.Contains(got, "rutracker-topic") { + t.Errorf("заглушка-dn просочилась в контекст как имя: %q", got) } } -func TestIngestEmptyNameOmitsRename(t *testing.T) { +// Реальная рутрекер-ссылка без текста: download.Context = релиз-заголовок из +// dn (раскодирован) + домен трекера. +func TestIngestRealRutrackerMagnetOnly(t *testing.T) { + const raw = "magnet:?xt=urn:btih:BACA24E18C7382A9E9A44132C8D7DB86C4D319C2" + + "&tr=http%3A%2F%2Fbt4.t-ru.org%2Fann%3Fmagnet" + + "&dn=%D0%91%D1%83%D1%85%D1%82%D0%B0%20%D0%B2%D0%B4%D0%BE%D0%B2%20%2F%20Widow's%20Bay%20%2F%20%D0%A1%D0%B5%D0%B7%D0%BE%D0%BD%3A%201%20%5B2026%2C%20%D0%A1%D0%A8%D0%90%2C%20WEB-DL%201080p%5D" fs := &fakeStore{} - fq := &fakeQbt{} - nm := &fakeNamer{name: ""} // имя не получено - if _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(), - Request{Source: sampleMagnet}); err != nil { + if _, err := newService(fs).Ingest(context.Background(), Request{Source: raw}); err != nil { t.Fatalf("Ingest: %v", err) } - if len(fq.added) != 1 || fq.added[0].Rename != "" { - t.Errorf("rename = %q, want пусто", fq.added[0].Rename) + ctx := fs.created[0].Context + if !strings.Contains(ctx, "Widow's Bay") || !strings.Contains(ctx, "Трекер: t-ru.org") { + t.Errorf("download.Context не обогащён: %q", ctx) } } func TestIngestIdempotent(t *testing.T) { - existing := &store.Download{ID: "01hzzzexisting000000000000", State: store.StateDownloading} + existing := &store.Download{ID: "01hzzzexisting000000000000", State: store.StateCatched} fs := &fakeStore{active: existing} - fq := &fakeQbt{} - res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet}) + res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet}) if err != nil { t.Fatalf("Ingest: %v", err) } @@ -179,9 +146,6 @@ func TestIngestIdempotent(t *testing.T) { if len(fs.created) != 0 { t.Error("не должно создаваться новой задачи") } - if len(fq.added) != 0 { - t.Error("не должно быть повторного добавления в qBittorrent") - } } // Быстрый дедуп-путь доносит существующей задаче недостающие хеши @@ -190,11 +154,11 @@ func TestIngestIdempotent(t *testing.T) { func TestIngestDedupTopsUpHashes(t *testing.T) { const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" existing := &store.Download{ - ID: "01hzzzexisting000000000000", State: store.StateDownloading, + ID: "01hzzzexisting000000000000", State: store.StateCatched, Infohashes: []store.Infohash{{DownloadID: "01hzzzexisting000000000000", Infohash: sampleInfohash, Kind: store.HashV1}}, } fs := &fakeStore{active: existing} - res, err := newService(fs, &fakeQbt{}).Ingest(context.Background(), + res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet + "&xt=urn:btmh:1220" + v2}) if err != nil { t.Fatalf("Ingest: %v", err) @@ -207,117 +171,12 @@ func TestIngestDedupTopsUpHashes(t *testing.T) { } } -// Голый magnet без текста: download.Context синтезируется из полей ссылки -// (dn-имя + размер), приём проходит штатно. -func TestIngestMagnetOnlySynthesizesContext(t *testing.T) { - const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" + - "&dn=Dune.Part.Two.2024.2160p&xl=2200000000" - fs := &fakeStore{} - if _, err := newService(fs, &fakeQbt{}).Ingest(context.Background(), - Request{Source: raw}); err != nil { - t.Fatalf("Ingest: %v", err) - } - if len(fs.created) != 1 { - t.Fatalf("создано задач: %d, want 1", len(fs.created)) - } - ctx := fs.created[0].Context - if !strings.Contains(ctx, "Dune.Part.Two.2024.2160p") || !strings.Contains(ctx, "Размер:") { - t.Errorf("контекст не синтезирован из magnet: %q", ctx) - } -} - -// Регресс B1: строки-факты синтеза (Трекер:/Размер:) не должны становиться -// отображаемым именем. Namer получает СЫРОЙ контекст (+ dn-hint), а domain -// уходит только в download.Context для recognition. Заглушка-dn как строка- -// название в контекст не попадает. -func TestIngestSynthFactsNeverBecomeName(t *testing.T) { - const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" + - "&dn=rutracker-topic-6514485&tr=http%3A%2F%2Fbt.t-ru.org%2Fann%3Fmagnet" - fs := &fakeStore{} - fq := &fakeQbt{} - nm := &fakeNamer{name: "rutracker-topic-6514485"} // как вывел бы фолбек из dn-hint - if _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(), - Request{Source: raw}); err != nil { - t.Fatalf("Ingest: %v", err) - } - if nm.gotContext != "" { - t.Errorf("namer получил не сырой контекст: %q", nm.gotContext) - } - if strings.Contains(fq.added[0].Rename, "Трекер") { - t.Errorf("строка-факт просочилась в rename: %q", fq.added[0].Rename) - } - got := fs.created[0].Context - if !strings.Contains(got, "t-ru.org") { - t.Errorf("download.Context не обогащён доменом трекера: %q", got) - } - if strings.Contains(got, "rutracker-topic") { - t.Errorf("заглушка-dn просочилась в контекст как имя: %q", got) - } -} - -// Реальная рутрекер-ссылка без текста: download.Context = релиз-заголовок из -// dn (раскодирован) + домен трекера; namer получает пустой контекст и dn-hint. -func TestIngestRealRutrackerMagnetOnly(t *testing.T) { - const raw = "magnet:?xt=urn:btih:BACA24E18C7382A9E9A44132C8D7DB86C4D319C2" + - "&tr=http%3A%2F%2Fbt4.t-ru.org%2Fann%3Fmagnet" + - "&dn=%D0%91%D1%83%D1%85%D1%82%D0%B0%20%D0%B2%D0%B4%D0%BE%D0%B2%20%2F%20Widow's%20Bay%20%2F%20%D0%A1%D0%B5%D0%B7%D0%BE%D0%BD%3A%201%20%5B2026%2C%20%D0%A1%D0%A8%D0%90%2C%20WEB-DL%201080p%5D" - fs := &fakeStore{} - nm := &fakeNamer{name: "Бухта вдов (2026)"} - if _, err := newServiceWithNamer(fs, &fakeQbt{}, nm).Ingest(context.Background(), - Request{Source: raw}); err != nil { - t.Fatalf("Ingest: %v", err) - } - if nm.gotContext != "" { - t.Errorf("namer получил не сырой контекст: %q", nm.gotContext) - } - ctx := fs.created[0].Context - if !strings.Contains(ctx, "Widow's Bay") || !strings.Contains(ctx, "Трекер: t-ru.org") { - t.Errorf("download.Context не обогащён: %q", ctx) - } -} - -func TestIngestQbitErrorMarksFailed(t *testing.T) { - fs := &fakeStore{} - fq := &fakeQbt{err: errors.New("connection refused")} - res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet}) - if err == nil { - t.Fatal("ожидалась ошибка") - } - if res.State != store.StateFailed { - t.Errorf("state = %q, want failed", res.State) - } - if len(fs.stateCalls) != 1 || fs.stateCalls[0].state != store.StateFailed { - t.Errorf("ожидался перевод в failed: %+v", fs.stateCalls) - } -} - -func TestIngestQbitErrorNotifies(t *testing.T) { - fs := &fakeStore{} - fq := &fakeQbt{err: errors.New("connection refused")} - svc := newService(fs, fq) - got := make(chan string, 1) - svc.SetFailureNotifier(func(id string) { got <- id }) - - if _, err := svc.Ingest(context.Background(), Request{Source: sampleMagnet}); err == nil { - t.Fatal("ожидалась ошибка") - } - select { - case id := <-got: - if id == "" { - t.Errorf("уведомление с пустым id") - } - case <-time.After(2 * time.Second): - t.Fatal("уведомление о падении приёма не пришло") - } -} - func TestIngestRejectsNonMagnet(t *testing.T) { fs := &fakeStore{} - fq := &fakeQbt{} - if _, err := newService(fs, fq).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil { + if _, err := newService(fs).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil { t.Fatal("ожидалась ошибка для не-magnet источника") } - if len(fs.created) != 0 || len(fq.added) != 0 { - t.Error("не должно быть ни записи, ни добавления") + if len(fs.created) != 0 { + t.Error("не должно быть записи задачи") } } diff --git a/internal/store/download.go b/internal/store/download.go index 2e56a8e..c0fad77 100644 --- a/internal/store/download.go +++ b/internal/store/download.go @@ -20,6 +20,7 @@ import ( type State string const ( + StateCatched State = "catched" // поймано и сохранено; worker добавит в qBittorrent StateDownloading State = "downloading" StateCompleted State = "completed" StateRecognizing State = "recognizing" // Ф2 @@ -503,6 +504,31 @@ func (s *Store) SetDownloadState(ctx context.Context, id string, state State, er return setState(ctx, s.DB, id, state, errCode, errMsg, false) } +// PromoteCatched переводит пойманную загрузку catched → downloading, попутно +// записывая выведенное отображаемое имя. Гард `state = 'catched'` — это +// ре-валидация: если загрузку успели отменить (catched → cancelled) во время +// вывода имени/добавления вне блокировки переходов, UPDATE не заденет ни строки +// и вернёт ошибку, а переход не применится. Пустое имя допустимо (rename не +// задавали) — тогда display_name так и остаётся пустым. +func (s *Store) PromoteCatched(ctx context.Context, id, displayName string) error { + res, err := s.DB.ExecContext(ctx, ` +UPDATE download +SET state = ?, display_name = ?, updated_at = ? +WHERE id = ? AND state = ?`, + string(StateDownloading), displayName, FormatTime(Now()), id, string(StateCatched)) + if err != nil { + return fmt.Errorf("promote catched %s: %w", id, err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("promote catched %s: %w", id, err) + } + if n == 0 { + return fmt.Errorf("promote catched %s: not in catched (already added or cancelled)", id) + } + return nil +} + // setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда // (ActivateIfNoOtherActive), которому переход терминал→активное разрешён; // иначе предикат в UPDATE не даёт молча оживить терминальную задачу. diff --git a/internal/store/download_test.go b/internal/store/download_test.go index b82d7ac..73ba909 100644 --- a/internal/store/download_test.go +++ b/internal/store/download_test.go @@ -27,6 +27,64 @@ func newDownloading() *Download { } } +func newCatched() *Download { + return &Download{ + SourceType: SourceMagnet, + SourceRef: "magnet:?xt=urn:btih:test", + Context: "ctx", + State: StateCatched, + } +} + +// catched — нетерминальное активное состояние: его наличие блокирует повторный +// приём того же infohash (инвариант «≤1 активная на infohash»). +func TestCatchedIsActiveForDedup(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + const ih = "aabbccddeeff00112233445566778899aabbccdd" + + d1 := newCatched() + if existing, err := st.CreateDownloadIfNoActive(ctx, d1, []string{ih}); err != nil || existing != nil { + t.Fatalf("первый catched: existing=%v err=%v", existing, err) + } + // Повторный приём того же хеша → дедуп на активную catched-задачу. + existing, err := st.CreateDownloadIfNoActive(ctx, newCatched(), []string{ih}) + if err != nil { + t.Fatalf("повторный приём: %v", err) + } + if existing == nil || existing.ID != d1.ID { + t.Errorf("ожидался дедуп на catched %s, got %v", d1.ID, existing) + } +} + +func TestPromoteCatched(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + const ih = "aabbccddeeff00112233445566778899aabbccdd" + + d := newCatched() + if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}); err != nil { + t.Fatalf("create: %v", err) + } + if err := st.PromoteCatched(ctx, d.ID, "Дюна (2024)"); err != nil { + t.Fatalf("promote: %v", err) + } + got, err := st.GetDownload(ctx, d.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.State != StateDownloading { + t.Errorf("state = %q, want downloading", got.State) + } + if got.DisplayName != "Дюна (2024)" { + t.Errorf("display_name = %q", got.DisplayName) + } + // Повторный promote (уже не catched) — отклоняется гардом state='catched'. + if err := st.PromoteCatched(ctx, d.ID, "X"); err == nil { + t.Error("ожидалась ошибка promote для не-catched задачи") + } +} + // mustCreate заводит загрузку с хешем и возвращает её id; дедуп на // существующую активную — ошибка теста. func mustCreate(t *testing.T, st *Store, infohash string) string { diff --git a/internal/store/list.go b/internal/store/list.go index 3c1bb24..ed59c7f 100644 --- a/internal/store/list.go +++ b/internal/store/list.go @@ -28,7 +28,7 @@ func statesInGroup(g StateGroup) []State { case GroupReview: return []State{StateReview, StateDeferred} case GroupActive: - return []State{StateDownloading, StateCompleted, StateRecognizing, StateLinking} + return []State{StateCatched, StateDownloading, StateCompleted, StateRecognizing, StateLinking} case GroupDone: return []State{StateDone} case GroupProblem: diff --git a/internal/tgbot/bot.go b/internal/tgbot/bot.go index 3dc8f70..07bfab7 100644 --- a/internal/tgbot/bot.go +++ b/internal/tgbot/bot.go @@ -151,9 +151,9 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) { b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil) return } - msg := fmt.Sprintf("Принято #%s — %s.", res.DownloadID, res.State) + msg := fmt.Sprintf("Принято #%s — добавляю в qBittorrent.", res.DownloadID) if res.Deduplicated { - msg = fmt.Sprintf("Уже в работе #%s — %s.", res.DownloadID, res.State) + msg = fmt.Sprintf("Уже в работе #%s.", res.DownloadID) } b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil) } diff --git a/internal/worker/catched_test.go b/internal/worker/catched_test.go new file mode 100644 index 0000000..4c05d2e --- /dev/null +++ b/internal/worker/catched_test.go @@ -0,0 +1,152 @@ +package worker + +import ( + "context" + "errors" + "testing" + "time" + + "git.vakhrushev.me/av/jellybit/internal/store" +) + +// fakeNamer — вывод имени для шага добавления. onCall позволяет вклиниться в +// момент (медленного) вывода имени, симулируя параллельную отмену. +type fakeNamer struct { + name string + gotContext string + onCall func() +} + +func (f *fakeNamer) DeriveName(_ context.Context, contextText, _ string) string { + f.gotContext = contextText + if f.onCall != nil { + f.onCall() + } + return f.name +} + +func catchedStore(id, infohash, createdAt, ctxText string) *fakeStore { + return &fakeStore{downloads: map[string]*store.Download{ + id: { + ID: id, + State: store.StateCatched, + SourceType: store.SourceMagnet, + SourceRef: "magnet:?xt=urn:btih:" + infohash + "&dn=Dune", + Infohashes: hashesOf(id, infohash), + Context: ctxText, + CreatedAt: createdAt, + }, + }} +} + +const catchedIH = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + +// now воркера в тестах — 2026-06-14 10:00 UTC (см. newTestWorker). +var nowStr = store.FormatTime(time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC)) + +// Успех: выводим имя, добавляем в qBit с rename, переводим catched → downloading +// и сохраняем display_name. +func TestProcessCatchedAddsToQbit(t *testing.T) { + st := catchedStore("1", catchedIH, nowStr, "Дюна 2") + qb := &fakeQbt{} + w := newTestWorker(st, qb) + nm := &fakeNamer{name: "Дюна: Часть вторая (2024)"} + w.SetNamer(nm) + + w.processCatched(context.Background()) + + if len(qb.added) != 1 { + t.Fatalf("qbt.Add calls = %d, want 1", len(qb.added)) + } + add := qb.added[0] + if add.Rename != "Дюна: Часть вторая (2024)" { + t.Errorf("rename = %q", add.Rename) + } + if add.Category != "jellybit" || add.URLs[0] != st.downloads["1"].SourceRef { + t.Errorf("add = %+v", add) + } + if nm.gotContext != "Дюна 2" { + t.Errorf("namer получил контекст %q", nm.gotContext) + } + d := st.downloads["1"] + if d.State != store.StateDownloading { + t.Errorf("state = %q, want downloading", d.State) + } + if d.DisplayName != "Дюна: Часть вторая (2024)" { + t.Errorf("display_name = %q", d.DisplayName) + } +} + +// Транзиентный сбой add — остаёмся в catched для повтора на следующем тике. +func TestProcessCatchedTransientFailureKeepsCatched(t *testing.T) { + st := catchedStore("1", catchedIH, nowStr, "ctx") + qb := &fakeQbt{addErr: errors.New("connection refused")} + w := newTestWorker(st, qb) + w.SetNamer(&fakeNamer{name: "X"}) + + w.processCatched(context.Background()) + + if st.downloads["1"].State != store.StateCatched { + t.Errorf("state = %q, want catched (повтор)", st.downloads["1"].State) + } +} + +// Предохранитель: catched старше catch_timeout → failed (qbit_add) + уведомление; +// add при этом не вызывается. +func TestProcessCatchedTimeoutFails(t *testing.T) { + old := store.FormatTime(time.Date(2026, 6, 14, 9, 0, 0, 0, time.UTC)) // 1 час до now + st := catchedStore("1", catchedIH, old, "ctx") + qb := &fakeQbt{} + w := newTestWorker(st, qb) + w.cfg.CatchTimeout = 10 * time.Minute + n := &recordingNotifier{ch: make(chan notifyEvent, 1)} + w.SetNotifier(n) + + w.processCatched(context.Background()) + + d := st.downloads["1"] + if d.State != store.StateFailed || d.ErrorCode.String != errCodeQbitAdd { + t.Errorf("state = %q code = %q, want failed/qbit_add", d.State, d.ErrorCode.String) + } + if len(qb.added) != 0 { + t.Errorf("add не должен вызываться при таймауте, calls = %d", len(qb.added)) + } + if e := waitNotify(t, n); e.ev != EventFailed { + t.Errorf("событие = %q, want failed", e.ev) + } +} + +// Ре-валидация: если во время сетевых вызовов (вне блокировки) задачу отменили, +// переход в downloading не применяется — состояние остаётся cancelled. +func TestProcessCatchedCancelledDuringAddSkipsPromote(t *testing.T) { + st := catchedStore("1", catchedIH, nowStr, "ctx") + qb := &fakeQbt{} + w := newTestWorker(st, qb) + // namer имитирует параллельную отмену во время (медленного) вывода имени. + nm := &fakeNamer{name: "X", onCall: func() { st.downloads["1"].State = store.StateCancelled }} + w.SetNamer(nm) + + w.processCatched(context.Background()) + + if len(qb.added) != 1 { + t.Fatal("add должен был вызваться (сеть идёт вне замка)") + } + if st.downloads["1"].State != store.StateCancelled { + t.Errorf("ре-валидация не сработала: state = %q, want cancelled", st.downloads["1"].State) + } +} + +// Поллинг активных (downloading) не трогает catched: раздачи в qBittorrent у +// пойманной загрузки ещё нет по дизайну, это не «пропажа». +func TestPollIgnoresCatched(t *testing.T) { + st := catchedStore("1", catchedIH, nowStr, "ctx") + qb := &fakeQbt{} // раздач нет + w := newTestWorker(st, qb) + + if err := w.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + if st.downloads["1"].State != store.StateCatched { + t.Errorf("catched тронут поллингом: %q", st.downloads["1"].State) + } +} diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index aed7b3a..8161899 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "io" "log/slog" "os" @@ -346,6 +347,16 @@ func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State return nil } +func (m *memStore) PromoteCatched(_ context.Context, id, displayName string) error { + d, ok := m.downloads[id] + if !ok || d.State != store.StateCatched { + return fmt.Errorf("promote catched %s: not in catched", id) + } + d.State = store.StateDownloading + d.DisplayName = displayName + return nil +} + func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error { if d, ok := m.downloads[id]; ok { d.SourceMissCount = n diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 03c9ffd..8aa390d 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -24,6 +24,7 @@ import ( "git.vakhrushev.me/av/jellybit/internal/ident" "git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/logctx" + "git.vakhrushev.me/av/jellybit/internal/magnet" "git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/store" @@ -44,6 +45,9 @@ type Store interface { ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error) GetDownload(ctx context.Context, id string) (*store.Download, error) SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error + // PromoteCatched атомарно переводит catched → downloading с записью имени + // (гард state='catched' — ре-валидация после сетевых вызовов вне блокировки). + PromoteCatched(ctx context.Context, id, displayName string) error SetSourceMissCount(ctx context.Context, id string, n int) error SetSourceAddedAt(ctx context.Context, id string, t time.Time) error @@ -85,6 +89,12 @@ type Recognizer interface { Recognize(ctx context.Context, in recognize.Input) (recognize.Result, error) } +// Namer выводит человекочитаемое отображаемое имя из контекста (naming.Namer). +// Пустой результат → rename в qBittorrent не задаём. nil → имя не выводим. +type Namer interface { + DeriveName(ctx context.Context, contextText, hint string) string +} + // Layouter — раскладчик хардлинками (layout.Layouter). type Layouter interface { BuildLinks(p layout.Plan) ([]layout.Link, error) @@ -111,6 +121,10 @@ const ( errCodeMagnetTimeout = "magnet_timeout" errCodeStalled = "stalled" errCodeQbitError = "qbit_error" + // errCodeQbitAdd — не удалось добавить пойманную загрузку в qBittorrent за + // catch_timeout (устойчивая недоступность qBit). Раздачи в qBittorrent нет, + // восстановлению сверкой не подлежит. + errCodeQbitAdd = "qbit_add" ) // Notifier — исходящие пинги (Telegram). Вызывается неблокирующе. @@ -134,6 +148,7 @@ type Config struct { PollInterval time.Duration StuckAfter time.Duration // stalledDL дольше → stuck MagnetTimeout time.Duration // metaDL дольше → failed + CatchTimeout time.Duration // catched дольше (не удалось добавить в qBit) → failed // SourceMissingThreshold — порог дебаунса пропажи источника (тиков сверки). // <1 трактуется как 1 (помечаем при первой же устойчивой пропаже). SourceMissingThreshold int @@ -181,6 +196,7 @@ type Worker struct { qbt QBittorrent recognizer Recognizer layouter Layouter + namer Namer // опц. вывод отображаемого имени на шаге добавления catched cfg Config log *slog.Logger @@ -209,6 +225,10 @@ type Worker struct { // одной задачи (см. failNotified). const failNotifyDebounce = time.Hour +// SetNamer подключает вывод отображаемого имени для шага добавления catched +// (до запуска Run). nil → имя не выводим, добавляем без rename. +func (w *Worker) SetNamer(n Namer) { w.namer = n } + // SetNotifier подключает исходящие пинги (до запуска Run). func (w *Worker) SetNotifier(n Notifier) { w.notifier = n } @@ -291,12 +311,95 @@ func (w *Worker) pollOnce(ctx context.Context) { if err := w.Poll(ctx); err != nil { w.log.Warn("poll failed", "error", err) } + // Быстрый приём отложил добавление в qBittorrent: подхватываем пойманные + // (catched) загрузки и добавляем их (сеть — вне блокировки переходов). + w.processCatched(ctx) // Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке). if w.recognizer != nil { w.recognizePending(ctx) } } +// processCatched — асинхронный шаг добавления пойманных загрузок в qBittorrent. +// Для каждой catched: (предохранитель) если висит дольше catch_timeout — уводим +// в failed; иначе выводим имя и добавляем в qBit. Медленные вызовы (LLM-namer, +// qbt.Add) идут ВНЕ w.mu, чтобы не задерживать команды транспортов и поллинг; +// под w.mu берутся только короткие DB-переходы (с ре-валидацией state=catched). +func (w *Worker) processCatched(ctx context.Context) { + w.mu.Lock() + catched, err := w.store.ListDownloadsByState(ctx, store.StateCatched) + w.mu.Unlock() + if err != nil { + w.log.Warn("list catched failed", "capability", capIngest, "error", err) + return + } + for _, d := range catched { + cctx := w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash()) + + // Предохранитель: устойчивая невозможность добавить в qBittorrent. + if w.cfg.CatchTimeout > 0 { + if age, ok := w.catchedAge(d); ok && age > w.cfg.CatchTimeout { + w.mu.Lock() + // Ре-валидация под замком: список catched снят раньше, задачу + // могли отменить (catched → cancelled) в это окно — тогда failed + // не навязываем (иначе затёрли бы cancelled и слали лишний пинг). + if cur, err := w.store.GetDownload(cctx, d.ID); err == nil && cur.State == store.StateCatched { + w.transition(cctx, d, store.StateFailed, errCodeQbitAdd, + fmt.Sprintf("not added to qBittorrent after %s", age.Truncate(time.Second))) + } + w.mu.Unlock() + continue + } + } + + // Вне w.mu: вывод имени (потенциально медленный LLM) и добавление. + var rename string + if w.namer != nil { + hint := "" + if info, perr := magnet.Parse(d.SourceRef); perr == nil { + hint = info.DisplayName + } + rename = w.namer.DeriveName(cctx, d.Context, hint) + } + addErr := w.qbt.Add(cctx, qbt.AddRequest{ + URLs: []string{d.SourceRef}, + Category: w.cfg.Category, + SavePath: w.cfg.SavePath, + Rename: rename, + }) + if addErr != nil { + // Транзиентный сбой (qBit недоступен) — остаёмся в catched, повтор на + // следующем тике. Поведение вызова qBit уже залогировал клиент (ext.*). + logctx.From(cctx).Warn("catched add to qbittorrent failed, will retry", "error", addErr) + continue + } + + // Успех: короткий переход под w.mu с ре-валидацией state=catched + // (загрузку могли отменить, пока шли сетевые вызовы). + w.mu.Lock() + if err := w.store.PromoteCatched(cctx, d.ID, rename); err != nil { + logctx.From(cctx).Info("catched promote skipped", "reason", err.Error()) + } else { + logctx.From(cctx).Info("state transition", "from", store.StateCatched, + "to", store.StateDownloading) + } + w.mu.Unlock() + } +} + +// catchedAge — возраст пойманной загрузки от created_at (у catched раздачи в +// qBittorrent ещё нет, added_on недоступен). ok=false — created_at не разобрать. +func (w *Worker) catchedAge(d store.Download) (time.Duration, bool) { + created, err := d.CreatedTime() + if err != nil { + w.log.Warn("cannot determine catched age", + "capability", capIngest, "download_id", d.ID, + "created_at", d.CreatedAt, "error", err) + return 0, false + } + return w.now().Sub(created), true +} + // Poll сверяет активные задачи с состоянием qBittorrent и двигает их. // Листаем все торренты (а не только свою категорию), чтобы reconcile нашёл и // усыновлённые по тегу раздачи, а discovery — увидел новые. diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index 71562f1..8c0c79d 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -163,6 +163,20 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.Stat return nil } +func (f *fakeStore) PromoteCatched(_ context.Context, id, displayName string) error { + d, ok := f.downloads[id] + if !ok { + return fmt.Errorf("download %s not found", id) + } + if d.State != store.StateCatched { + return fmt.Errorf("promote catched %s: not in catched (%s)", id, d.State) + } + d.State = store.StateDownloading + d.DisplayName = displayName + f.transitions = append(f.transitions, transition{id, store.StateDownloading}) + return nil +} + func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error { d, ok := f.downloads[id] if !ok { diff --git a/openspec/changes/archive/2026-07-07-fast-catch-ingest/.openspec.yaml b/openspec/changes/archive/2026-07-07-fast-catch-ingest/.openspec.yaml new file mode 100644 index 0000000..aee4ef1 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-fast-catch-ingest/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-07 diff --git a/openspec/changes/archive/2026-07-07-fast-catch-ingest/design.md b/openspec/changes/archive/2026-07-07-fast-catch-ingest/design.md new file mode 100644 index 0000000..083a3cf --- /dev/null +++ b/openspec/changes/archive/2026-07-07-fast-catch-ingest/design.md @@ -0,0 +1,150 @@ +## Context + +Текущий `Ingest` (`internal/ingest/ingest.go`) синхронно: парсит magnet, +дедуплицирует, выводит имя через `namer.DeriveName` (потенциально **медленный +LLM** — `extractViaLLM`), создаёт `download` сразу в `downloading` и вызывает +`qbt.Add`, и лишь затем возвращает `Result`. Медленный LLM и внешний вызов к +qBit задерживают ответ транспорту (HTTP/веб-UI/Telegram) и расширяют окно +«строка в БД есть, в qBittorrent ещё нет». + +Worker (`internal/worker`) уже ведёт поллинг-цикл под единой блокировкой +переходов: сверяет раздачи (`discover` усыновляет по категории/тегу с дедупом +по infohash), ходит по `ListDownloadsByState(StateDownloading)` и реагирует на +«active download not found in qbittorrent». Реконсилятор оперирует своими +`desyncStates` (`orphaned`, …), в которые `catched` не входит. + +Состояния — `internal/store/download.go` (`State`, `terminalStates`, +`statesInGroup`). Активность выводится из `state` (нетерминальное = активное). + +## Goals / Non-Goals + +**Goals:** + +- Быстрый ответ приёма: синхронно только парс + дедуп + запись `catched`. +- Вынести медленный вывод имени и `qbt.Add` в асинхронный шаг worker'а. +- Сохранить инвариант «≤1 активная загрузка на infohash» (в т.ч. с `catched`). +- Корректно показать `catched` в веб-UI; не считать его пропажей раздачи. + +**Non-Goals:** + +- Отдельное состояние `added-to-qbittorrent` — схлопнуто в `catched → + downloading` (решение развилки). +- Немедленный пинок фоновой добавки — двигаем worker-циклом (решение развилки); + задержка ≤ `poll_interval` приемлема, ведь клиенту уже ответили. +- Изменение схемы БД, API транспортов, распознавания/раскладки. +- Перенос синтеза контекста из полей magnet — он дёшев и остаётся в приёме. + +## Decisions + +### Р1. Новое состояние `catched`, нетерминальное активное + +`StateCatched = "catched"`. Не входит в `terminalStates` → автоматически +считается активным для `CreateDownloadIfNoActive` и инварианта. Добавляется в +`statesInGroup(GroupActive)` рядом с `downloading` — чтобы попадать в активную +группу списка и в поиск. + +### Р2. Приём пишет `catched`, без namer и без qBit + +`Ingest`: парс → синтез контекста (как сейчас) → `CreateDownloadIfNoActive` с +`State: StateCatched`, `DisplayName: ""` (имя выведет worker). Ни +`namer.DeriveName`, ни `qbt.Add` в приёме не вызываются. `Result` возвращается +сразу после записи. Транспорты (`httpapi`, `tgbot`) не меняются — они уже +работают через `Result`. + +Зависимость `Namer` из `ingest.Service` **переезжает** в worker (или worker +получает её отдельно). `ingest` перестаёт зависеть от `naming`/`qbt` в пути +приёма (qbt-зависимость в ingest может уйти совсем, если не нужна для дедупа). + +### Р3. Асинхронный шаг worker'а: `catched → downloading`, сеть — вне замка + +**Критично:** `w.mu` в worker'е сериализует ВЕСЬ поллинг-цикл И команды +транспортов (`Cancel`, `Retry`, review). Медленный `namer.DeriveName` (до +`max_retries` сетевых попыток) и `qbt.Add` под этим замком заморозили бы все +действия пользователя на секунды каждый тик — это ровно та блокировка, которую +change устраняет. Поэтому: + +1. Под `w.mu` (быстро): снять список `ListDownloadsByState(StateCatched)`. +2. **Вне `w.mu`** (для каждой загрузки): вывести имя `namer.DeriveName(ctx, + d.Context, dnHint)`, вызвать `qbt.Add(urls=d.SourceRef, category, savepath, + rename=name)`. Имя выводится непосредственно перед `add` (`rename` действует + только при добавлении). +3. Снова под `w.mu` (быстро): **ре-валидировать** `state == catched` (мог быть + отменён/добавлен параллельно) и записать переход `catched → downloading` + + `display_name`. Ре-валидацию обеспечивает гард `setState` (target + нетерминальный → `state NOT IN terminalStates`): если пользователь успел + `catched → cancelled`, переход корректно отклонится. + +То есть под сериализацией переходов — только запись перехода в БД, а не сетевые +вызовы. Спека («Добавление пойманной загрузки») формулирует это так же: под +блокировкой сериализуется переход, не `add`/namer. + +`dnHint` (dn из magnet) worker получает разбором `d.SourceRef` +(`magnet.Parse`) — дёшево, без сети; для `add` используется сам `d.SourceRef` +(URL), хеши уже есть в `d.Infohashes`. + +_Альтернатива:_ хранить hint отдельным полем. Отвергнуто — `SourceRef` уже есть, +повторный парс тривиален, схему не трогаем. + +### Р4. Сбой `add` — ретрай тиком, предохранитель `catch_timeout` + +Парс magnet уже прошёл синхронно в приёме, поэтому в `catched` ссылка +валидна — сбои `add` почти всегда транзиентны (qBit недоступен). Поэтому +неуспешный `add` **оставляет** загрузку в `catched` (повтор на следующем тике), +а не уводит в `failed` по первому сбою. Страховка от устойчивой недоступности — +предохранитель `catch_timeout` (новый конфиг, дефолт консервативный, напр. по +образцу `magnet_timeout`): `catched` старше него → `failed` (`qbit_add`) + +уведомление автора. Это переиспользует существующий паттерн +таймаутов-предохранителей (`magnet_timeout`/`stuck_after`). + +### Р5. `catched` исключён из проверок пропажи раздачи + +Поллинг активных (`worker.go`: `ListDownloadsByState(StateDownloading)`) уже не +включает `catched` — но фиксируем это требованием и тестом. Реконсилятор +(`desyncStates`) `catched` не содержит. `discover`: когда worker добавит +раздачу catched-загрузки, следующий тик увидит её по категории, но exists-чек по +infohash найдёт активную загрузку и не заведёт дубль (инвариант держится). + +### Р6. Веб-UI: бейдж/фаза `catched` + +Добавить подпись бейджа и фазу жизненного цикла для `catched` (перед +`downloading`), включить в активную группу. Заголовок при пустом `display_name` +уже деградирует по фолбеку. Секции раздачи/живого прогресса для `catched` нет +(нет qBit-записи) — шаблон должен это переносить без ошибок (обычно уже так, т.к. +телеметрия ищется по infohash и не находится). + +## Risks / Trade-offs + +- [Задержка появления в qBit до ~`poll_interval` (5с)] → Приемлемо: клиенту уже + ответили; пользователь видит `catched` в UI. При желании позже — немедленный + пинок, но вне объёма. +- [Гонка discover ↔ шаг добавления (worker добавил, тот же/следующий тик + усыновляет)] → Дедуп по infohash в `discover` (exists-чек) уже защищает; + оба пути под общей блокировкой переходов. +- [Пустой `display_name` в `catched` виден в UI] → Фолбек заголовка уже есть + (распознанное/усечённый источник); визуально корректно. +- [Namer/LLM-ошибка на шаге добавления] → Как и раньше best-effort: пустое имя → + `add` без `rename`; шаг добавления не срывается из-за namer. +- [Учёт `catched` во всех местах, где перечислены активные состояния] → + Единая точка `statesInGroup` + аудит по `StateDownloading`-упоминаниям в + worker/store; покрыть тестами дедупа и группировки. + +## Migration Plan + +Аддитивно: новое состояние и новый конфиг `catch_timeout` (с дефолтом — старый +конфиг валиден). Существующие загрузки в `downloading`/терминальных не +затронуты. Новый путь приёма применяется к новым загрузкам. + +Откат — ревертом кода. Загрузки, застрявшие в `catched` на момент отката, +старая логика не знает и `retry` их не поднимет (`Retry` разрешён только из +`failed`/`stuck`), а как активные они ещё и блокируют повторный приём того же +infohash. Окно мало (`catched` живёт секунды до тика worker'а), но при откате +такие строки нужно снять вручную: `UPDATE download SET state='failed', +error_code='qbit_add' WHERE state='catched'` — после чего они доступны штатному +`retry`. Зафиксировать в задаче/рантбуке отката. + +## Open Questions + +- Значение дефолта `catch_timeout` (5–15 мин?) — уточнить при apply, на спеку + не влияет. +- Нужен ли отдельный `error_code` для `catch_timeout` или переиспользуем + `qbit_add` (взято `qbit_add`, т.к. корень — невозможность добавить). diff --git a/openspec/changes/archive/2026-07-07-fast-catch-ingest/proposal.md b/openspec/changes/archive/2026-07-07-fast-catch-ingest/proposal.md new file mode 100644 index 0000000..eb07ed8 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-fast-catch-ingest/proposal.md @@ -0,0 +1,78 @@ +## Why + +Сейчас приём (`Ingest`) синхронно делает всё: парсит источник, выводит +отображаемое имя (потенциально **медленный вызов LLM** в `namer.DeriveName`) и +добавляет источник в qBittorrent — и только потом отвечает клиенту. Долгий LLM +и внешний запрос к qBit задерживают ответ HTTP API / веб-UI / Telegram и +расширяют окно «строка в БД есть, в qBittorrent ещё нет». + +Идея: сделать приём **быстрым** — синхронно только валидировать и сохранить +загрузку (новое состояние `catched`), сразу вернув ответ; вывод имени и +добавление в qBittorrent вынести в отдельный **асинхронный шаг машины +состояний**, который двигает worker. + +## What Changes + +- Новое состояние **`catched`** — загрузка поймана и персистентно сохранена + (быстрый путь). Нетерминальное, активное (участвует в инварианте «≤1 активная + загрузка на infohash»). +- **Приём (`ingest`) — быстрый**: парс magnet, извлечение инфохэшей, синтез + контекста из полей ссылки (дёшево, без сети), атомарный дедуп и запись + загрузки в `catched`. Ответ клиенту сразу. Синхронного вывода имени и + добавления в qBittorrent в приёме больше нет. +- **Асинхронный шаг (`download-tracking`, worker)**: на каждом тике worker + подхватывает `catched`-загрузки, выводит отображаемое имя из контекста (LLM + + фолбек), добавляет источник в qBittorrent (категория/savepath/rename) и + переводит `catched → downloading` (отдельного `added-to-qbittorrent` нет — + `downloading` и так значит «в qBit, возможно metaDL»). +- **Обработка сбоев вне запроса клиента**: неуспешный `add` оставляет загрузку + в `catched` (worker перетыкивает на следующем тике); предохранитель + `catch_timeout` уводит долго-зависший `catched` в `failed` + (`error_code` `qbit_add`) с уведомлением автора. +- `catched` **исключён** из проверок «раздача не найдена» (у него раздачи нет + по дизайну) — ни поллинг, ни сверка не считают его рассинхроном/orphaned. +- **Веб-UI** показывает промежуточное состояние `catched` (бейдж/фаза + жизненного цикла), заголовок деградирует, пока имя не выведено (фолбек уже + есть). `catched` попадает в активную группу списка. + +## Capabilities + +### New Capabilities + +_Нет._ Изменение переиспользует существующие capabilities. + +### Modified Capabilities + +- `ingest`: приём становится быстрым — сохранение в `catched` и мгновенный + ответ; синхронный вывод имени и добавление в qBittorrent из приёма убраны + (переезжают в асинхронный шаг). Требования по выводу имени переформулированы: + выполняются на шаге добавления, а не в пути ответа клиента. +- `download-tracking`: добавляется шаг «добавление пойманной загрузки в + qBittorrent» (вывод имени + `add` + переход `catched → downloading`), + предохранитель `catch_timeout`, исключение `catched` из проверок пропажи + раздачи. +- `web-ui`: человекочитаемый бейдж для `catched`; активная группа включает + `catched`; заголовок при пустом имени — по фолбеку; карточка `catched` + самообновляется htmx-поллингом до перехода в `downloading`. + +_Без спек-правок:_ `state-reconciliation` и `live-status` не меняются. +Исключение `catched` из проверок рассинхрона нормативно закреплено требованием +`download-tracking` «catched не считается пропажей раздачи» (само поведение уже +верно — `catched` не входит в `desyncStates` и дебаунс пропажи источника его не +трогает). Иллюстративное перечисление активных состояний в тексте +`state-reconciliation` остаётся на кросс-ссылке и будет выверено при следующем +касании этой спеки. `live-status`: у `catched` нет qBit-телеметрии по дизайну — +живой прогресс для него корректно отсутствует. + +## Impact + +- Код: `internal/store` (состояние `StateCatched`, группа active), `internal/ + ingest` (быстрый путь: убрать namer/qbt-add, писать `catched`), `internal/ + worker` (новый шаг обработки `catched`: namer + qbt-add + переход, таймаут), + `internal/httpapi`+`internal/tgbot` (без правок API — ответ и так по Result), + веб-UI шаблоны (бейдж/фаза `catched`). +- Конфиг: новый `catch_timeout` (предохранитель), дефолт консервативный. +- Данные: у активной загрузки теперь есть фаза без infohash-раздачи; схема БД + не меняется (используем существующие `state`/`error_code`/`error_msg`). +- Совместимость: существующие загрузки не затронуты; переход одноразовый на + уровне логики приёма. diff --git a/openspec/changes/archive/2026-07-07-fast-catch-ingest/specs/download-tracking/spec.md b/openspec/changes/archive/2026-07-07-fast-catch-ingest/specs/download-tracking/spec.md new file mode 100644 index 0000000..ac09fe5 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-fast-catch-ingest/specs/download-tracking/spec.md @@ -0,0 +1,78 @@ +## ADDED Requirements + +### Requirement: Добавление пойманной загрузки в qBittorrent + +Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов) +подхватывать загрузки в состоянии `catched` и для каждой: вывести отображаемое +имя из контекста (см. `ingest` «Отображаемое имя торрента из контекста»), +добавить источник в qBittorrent (категория `qbittorrent.category`, savepath, +`rename`) и перевести загрузку `catched → downloading`. Отдельного состояния +между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит +в `downloading` (которое и означает «в qBit, возможно `metaDL`»). + +Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в +`catched` для повторной попытки на следующем тике; переход в терминальное +состояние по единичному сбою происходить SHALL NOT (ретраи — естественными +тиками поллинга). + +Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне** +блокировки сериализации переходов, чтобы не задерживать команды транспортов и +поллинг. Под блокировкой сериализуется только **запись перехода** `catched → +downloading` (см. «Переходы состояний сериализуются воркером»), с +ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется — +например, при параллельной отмене). + +#### Scenario: Пойманная загрузка добавляется в qBittorrent + +- **GIVEN** загрузка в состоянии `catched` +- **WHEN** worker обрабатывает тик +- **THEN** выводится отображаемое имя, источник добавляется в qBittorrent с + нашей категорией и `rename` +- **AND** загрузка переходит в `downloading` + +#### Scenario: Временный сбой добавления — повтор + +- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен +- **WHEN** worker пытается добавить источник и `add` не удался +- **THEN** загрузка остаётся в `catched` +- **AND** на следующем тике попытка добавления повторяется + +#### Scenario: Отмена во время добавления + +- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне + блокировки +- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем + worker берёт блокировку для записи перехода +- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в + `downloading` не применяется + +### Requirement: Предохранитель зависшего catched + +Система SHALL переводить загрузку, задержавшуюся в `catched` дольше +`catch_timeout` (конфигурируемый предохранитель, дефолт консервативный), в +`failed` (`error_code` `qbit_add`) и уведомлять автора. Возраст SHALL считать +от времени попадания в `catched` (создания загрузки). Предохранитель — +редкий страховочный механизм на случай устойчивой недоступности qBittorrent, а +не штатный путь. + +#### Scenario: catched висит дольше таймаута + +- **GIVEN** загрузка в `catched` дольше `catch_timeout` +- **WHEN** идёт тик поллинга +- **THEN** загрузка переходит в `failed` с `error_code` `qbit_add` +- **AND** автор загрузки уведомляется + +### Requirement: catched не считается пропажей раздачи + +Система SHALL исключать состояние `catched` из проверок «раздача не найдена в +qBittorrent» — как в поллинге активных загрузок, так и в сверке рассинхрона +(`state-reconciliation`). У пойманной загрузки раздачи в qBittorrent ещё нет по +дизайну, поэтому её отсутствие система SHALL NOT трактовать как рассинхрон, +`orphaned` или пропажу источника. + +#### Scenario: Отсутствие раздачи у catched — не рассинхрон + +- **GIVEN** загрузка в `catched` (раздачи в qBittorrent ещё нет) +- **WHEN** идёт тик поллинга и сверки +- **THEN** загрузка не считается пропавшей/рассинхронизированной и остаётся в + `catched` (до добавления воркером или срабатывания `catch_timeout`) diff --git a/openspec/changes/archive/2026-07-07-fast-catch-ingest/specs/ingest/spec.md b/openspec/changes/archive/2026-07-07-fast-catch-ingest/specs/ingest/spec.md new file mode 100644 index 0000000..be7d009 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-fast-catch-ingest/specs/ingest/spec.md @@ -0,0 +1,81 @@ +## MODIFIED Requirements + +### Requirement: Приём источника и заведение загрузки + +Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP, +Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL +синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети), +дедуплицировать по активной задаче и при отсутствии дубля завести загрузку +(`download` в состоянии **`catched`** + записи `download_infohash`), после чего +**сразу вернуть ответ** транспорту. Заведение загрузки и запись её хешей SHALL +выполняться атомарно (см. «Атомарность возврата загрузки в активное +состояние»). + +Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить +отображаемое имя (потенциально медленный LLM): и добавление источника в +qBittorrent, и вывод имени выполняются отдельным асинхронным шагом машины +состояний (worker) — см. `download-tracking` «Добавление пойманной загрузки в +qBittorrent». + +`catched` — нетерминальное активное состояние: оно участвует в инварианте «не +более одной активной загрузки на infohash» наравне с прочими активными. + +#### Scenario: Быстрый приём magnet + +- **GIVEN** валидная magnet-ссылка и контекст +- **WHEN** вызывается приём +- **THEN** создаётся `download` в состоянии `catched` с записями + `download_infohash` +- **AND** ответ транспорту отдан без обращения к qBittorrent и без вывода имени + +#### Scenario: Дубль по активной задаче на быстром пути + +- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash +- **WHEN** вызывается приём +- **THEN** новая загрузка не создаётся, возвращается существующая + +### Requirement: Отображаемое имя торрента из контекста + +На шаге добавления пойманной загрузки в qBittorrent (worker) система SHALL +выводить из контекста загрузки человекочитаемое отображаемое имя и передавать +его в qBittorrent (параметр `rename` API `/torrents/add`), чтобы задача в списке +qBit не показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL +**сохранять у загрузки** (`download.display_name`) для последующего показа +заголовком в веб-UI. + +Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и +год; для сериала — номер сезона, если он определён), а не куском сырого +контекста. Имя SHALL очищаться от управляющих символов и переводов строк и +SHALL обрезаться по ограничению длины. + +Вывод имени SHALL выполняться на шаге добавления, непосредственно перед вызовом +`add` (параметр `rename` действует только в момент добавления), а НЕ в +синхронном пути ответа приёма. В состоянии `catched` (до добавления) +`download.display_name` ещё пуст — веб-UI берёт заголовок из фолбека. + +Отображаемое имя SHALL влиять только на отображение (в qBittorrent и как +заголовок в веб-UI) и SHALL NOT влиять на пути файлов на диске, распознавание +или раскладку — реальные пути система по-прежнему читает из qBit API. + +#### Scenario: Имя из контекста передаётся в qBittorrent + +- **WHEN** на шаге добавления получен непустой контекст, из которого удалось + вывести имя +- **THEN** система передаёт это имя в qBittorrent в параметре `rename` +- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)», + где режиссёр и год опциональны + +#### Scenario: Имя сохраняется у загрузки + +- **WHEN** на шаге добавления выведено непустое отображаемое имя +- **THEN** система сохраняет его в `download.display_name` (обновлением записи + загрузки) +- **AND** веб-UI использует его заголовком карточки и страницы загрузки + +#### Scenario: Контекст пуст или имя не получено + +- **WHEN** контекста нет либо ни один способ вывода не дал непустого имени +- **THEN** система добавляет загрузку без параметра `rename` +- **AND** qBittorrent оставляет собственное имя (из `dn`/торрента) +- **AND** `download.display_name` остаётся пустым, а веб-UI берёт заголовок из + фолбека (распознанное название или усечённый источник) diff --git a/openspec/changes/archive/2026-07-07-fast-catch-ingest/specs/web-ui/spec.md b/openspec/changes/archive/2026-07-07-fast-catch-ingest/specs/web-ui/spec.md new file mode 100644 index 0000000..2b6e36e --- /dev/null +++ b/openspec/changes/archive/2026-07-07-fast-catch-ingest/specs/web-ui/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: Отображение промежуточного состояния catched + +Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу +(«поймано, добавляется в qBittorrent»): бейдж статуса загрузки SHALL иметь +понятную человекочитаемую подпись для `catched` (а не сырое `catched`), а +загрузка в `catched` SHALL относиться к **активной** группе списка. + +Пока отображаемое имя ещё не выведено (в `catched` `download.display_name` +пуст), заголовок загрузки SHALL деградировать по существующему фолбеку +(распознанное название или усечённый источник) — см. «Заголовок загрузки из +имени раздачи». Секция раздачи/живого прогресса для `catched` SHALL корректно +отсутствовать (раздачи в qBittorrent ещё нет), не создавая ошибок отображения. + +Карточка/страница загрузки в `catched` SHALL самообновляться самозавершающимся +htmx-поллингом (см. конвенцию веб-UI): по переходе загрузки в `downloading` +интерфейс SHALL отражать это без перезагрузки страницы (подхватить бейдж, +выведенное имя и появившийся живой прогресс), а поллинг фазы `catched` SHALL +завершаться, как только загрузка её покинула. + +#### Scenario: Бейдж и группа для catched + +- **WHEN** загрузка находится в состоянии `catched` +- **THEN** её бейдж статуса имеет человекочитаемую подпись для `catched` +- **AND** загрузка попадает в активную группу списка + +#### Scenario: Заголовок catched без имени + +- **GIVEN** загрузка в `catched` с пустым `download.display_name` +- **WHEN** рендерится карточка/страница загрузки +- **THEN** заголовок берётся из фолбека (распознанное название или усечённый + источник), без ошибок отображения +- **AND** секция раздачи/живого прогресса не показывается (раздачи ещё нет) + +#### Scenario: Самообновление при переходе в downloading + +- **GIVEN** открытая карточка загрузки в `catched` +- **WHEN** worker перевёл загрузку в `downloading` +- **THEN** интерфейс без перезагрузки показывает состояние `downloading` + (бейдж, имя, живой прогресс) +- **AND** поллинг фазы `catched` завершается diff --git a/openspec/changes/archive/2026-07-07-fast-catch-ingest/tasks.md b/openspec/changes/archive/2026-07-07-fast-catch-ingest/tasks.md new file mode 100644 index 0000000..ca44c69 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-fast-catch-ingest/tasks.md @@ -0,0 +1,77 @@ +## 1. Состояние catched + +- [x] 1.1 Добавить `StateCatched State = "catched"` в `internal/store/download.go`; + убедиться, что оно НЕ в `terminalStates` (нетерминальное = активное) +- [x] 1.2 Включить `StateCatched` в `statesInGroup(GroupActive)`; проверить + поиск/фильтры списка +- [x] 1.3 Тест: `catched` активно для дедупа (`CreateDownloadIfNoActive` держит + инвариант ≤1 активная на infohash с участием `catched`) + +## 2. Быстрый приём (ingest) + +- [x] 2.1 `Ingest`: писать `download` в `StateCatched` с пустым `DisplayName`; + убрать из синхронного пути `namer.DeriveName` и `qbt.Add`; вернуть `Result` + сразу после записи +- [x] 2.2 Убрать из `ingest.Service` ставшие ненужными зависимости/код: + `Namer`, `QBittorrent`, `notifyFailed`/`SetFailureNotifier`, `qbit_add`-путь + падения add (падение теперь у worker'а — п.4) +- [x] 2.3 Обновить тесты ingest: приём создаёт `catched`, не зовёт qBit/namer; + дедуп по активной (в т.ч. `catched`); синтез контекста сохраняется как прежде +- [x] 2.4 Транспорты (`httpapi`, `tgbot`): ответ по `Result` корректен для + `catched` (без правок API); Telegram-текст не показывает сырое `catched` + (дружелюбная формулировка «принято/добавляется») + +## 3. Асинхронный шаг добавления (worker) + +- [x] 3.1 Внедрить в worker зависимости `Namer` и `qbt.Add`; добавить в + интерфейс `worker.Store` новый метод записи имени (`SetDisplayName`) +- [x] 3.2 Шаг в поллинг-цикле, сеть ВНЕ `w.mu`: под замком снять + `ListDownloadsByState(StateCatched)`; вне замка для каждой — разобрать + `d.SourceRef` (`magnet.Parse`) для dn-hint, вывести имя (`namer.DeriveName`), + вызвать `qbt.Add(urls=d.SourceRef, category, savepath, rename)`; снова под + замком — ре-валидировать `state==catched` и записать переход `catched → + downloading` + `display_name` +- [x] 3.3 Сбой `add`: оставить в `catched` (повтор на следующем тике), не уводить + в терминальное по единичному сбою; логировать (ext.* уже логирует клиент) +- [x] 3.4 Тесты worker: `catched → downloading` при успехе (rename передан, + display_name сохранён, сетевые вызовы вне замка); транзиентный сбой оставляет + `catched` и повторяет; отмена во время add (ре-валидация отбрасывает переход) + +## 4. Предохранитель catch_timeout + +- [x] 4.1 Конфиг `catch_timeout` (+ дефолт, валидация на старте) — по образцу + `magnet_timeout`; документация конфига +- [x] 4.2 В поллинг-цикле: `catched` старше `catch_timeout` (от создания) → + `failed` (`error_code` `qbit_add`) + уведомление автора +- [x] 4.3 Тест: `catched` за таймаутом → `failed` + notify + +## 5. Исключение catched из проверок пропажи + +- [x] 5.1 Убедиться (и закрепить тестом), что поллинг активных и сверка + рассинхрона (`state-reconciliation`) не трактуют `catched` как пропажу/ + orphaned +- [x] 5.2 Тест на гонку discover ↔ шаг добавления: дубль по infohash не заводится + +## 6. Веб-UI + +- [x] 6.1 Человекочитаемая подпись бейджа для `catched` (`badgeLabel`), чтобы не + показывать сырое `catched`; `catched` в активной группе списка +- [x] 6.2 Заголовок при пустом `display_name` (фолбек) и отсутствие секции + раздачи/живого прогресса для `catched` без ошибок +- [x] 6.3 Самозавершающийся htmx-поллинг карточки/страницы в `catched`: пока + `catched` — опрашивает фрагмент; по переходе в `downloading` показывает + прогресс/имя без перезагрузки и завершает поллинг фазы (правка live-рендера + для не-`downloading` активной фазы) +- [x] 6.4 Тест httpapi/шаблонов: карточка/страница `catched` рендерится; + фрагмент-поллинг отдаётся для `catched` и завершается после перехода + +## 7. Проверка + +- [x] 7.1 `task test` и `task lint` зелёные +- [x] 7.2 `openspec validate fast-catch-ingest --strict` проходит +- [x] 7.3 Прогон вручную/через verify: приём отвечает быстро (без LLM в пути), + загрузка проходит `catched → downloading`, карточка обновляется без + перезагрузки +- [x] 7.4 Рантбук отката: снять застрявшие `catched` (`UPDATE download SET + state='failed', error_code='qbit_add' WHERE state='catched'`) — зафиксировать + в описании change/задаче diff --git a/openspec/specs/download-tracking/spec.md b/openspec/specs/download-tracking/spec.md index 4c2ea86..8f8017e 100644 --- a/openspec/specs/download-tracking/spec.md +++ b/openspec/specs/download-tracking/spec.md @@ -92,3 +92,80 @@ Worker SHALL периодически сверять раздачи qBittorrent - **WHEN** они обрабатываются - **THEN** переходы применяются последовательно под блокировкой, без гонки +### Requirement: Добавление пойманной загрузки в qBittorrent + +Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов) +подхватывать загрузки в состоянии `catched` и для каждой: вывести отображаемое +имя из контекста (см. `ingest` «Отображаемое имя торрента из контекста»), +добавить источник в qBittorrent (категория `qbittorrent.category`, savepath, +`rename`) и перевести загрузку `catched → downloading`. Отдельного состояния +между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит +в `downloading` (которое и означает «в qBit, возможно `metaDL`»). + +Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в +`catched` для повторной попытки на следующем тике; переход в терминальное +состояние по единичному сбою происходить SHALL NOT (ретраи — естественными +тиками поллинга). + +Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне** +блокировки сериализации переходов, чтобы не задерживать команды транспортов и +поллинг. Под блокировкой сериализуется только **запись перехода** `catched → +downloading` (см. «Переходы состояний сериализуются воркером»), с +ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется — +например, при параллельной отмене). + +#### Scenario: Пойманная загрузка добавляется в qBittorrent + +- **GIVEN** загрузка в состоянии `catched` +- **WHEN** worker обрабатывает тик +- **THEN** выводится отображаемое имя, источник добавляется в qBittorrent с + нашей категорией и `rename` +- **AND** загрузка переходит в `downloading` + +#### Scenario: Временный сбой добавления — повтор + +- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен +- **WHEN** worker пытается добавить источник и `add` не удался +- **THEN** загрузка остаётся в `catched` +- **AND** на следующем тике попытка добавления повторяется + +#### Scenario: Отмена во время добавления + +- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне + блокировки +- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем + worker берёт блокировку для записи перехода +- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в + `downloading` не применяется + +### Requirement: Предохранитель зависшего catched + +Система SHALL переводить загрузку, задержавшуюся в `catched` дольше +`catch_timeout` (конфигурируемый предохранитель, дефолт консервативный), в +`failed` (`error_code` `qbit_add`) и уведомлять автора. Возраст SHALL считать +от времени попадания в `catched` (создания загрузки). Предохранитель — +редкий страховочный механизм на случай устойчивой недоступности qBittorrent, а +не штатный путь. + +#### Scenario: catched висит дольше таймаута + +- **GIVEN** загрузка в `catched` дольше `catch_timeout` +- **WHEN** идёт тик поллинга +- **THEN** загрузка переходит в `failed` с `error_code` `qbit_add` +- **AND** автор загрузки уведомляется + +### Requirement: catched не считается пропажей раздачи + +Система SHALL исключать состояние `catched` из проверок «раздача не найдена в +qBittorrent» — как в поллинге активных загрузок, так и в сверке рассинхрона +(`state-reconciliation`). У пойманной загрузки раздачи в qBittorrent ещё нет по +дизайну, поэтому её отсутствие система SHALL NOT трактовать как рассинхрон, +`orphaned` или пропажу источника. + +#### Scenario: Отсутствие раздачи у catched — не рассинхрон + +- **GIVEN** загрузка в `catched` (раздачи в qBittorrent ещё нет) +- **WHEN** идёт тик поллинга и сверки +- **THEN** загрузка не считается пропавшей/рассинхронизированной и остаётся в + `catched` (до добавления воркером или срабатывания `catch_timeout`) + diff --git a/openspec/specs/ingest/spec.md b/openspec/specs/ingest/spec.md index fd9408d..8de5469 100644 --- a/openspec/specs/ingest/spec.md +++ b/openspec/specs/ingest/spec.md @@ -11,20 +11,22 @@ ## Requirements ### Requirement: Отображаемое имя торрента из контекста -При добавлении загрузки в qBittorrent система SHALL выводить из контекста -загрузки человекочитаемое отображаемое имя и передавать его в qBittorrent -(параметр `rename` API `/torrents/add`), чтобы задача в списке qBit не -показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL **сохранять -у загрузки** (`download.display_name`) для последующего показа заголовком в -веб-UI. +На шаге добавления пойманной загрузки в qBittorrent (worker) система SHALL +выводить из контекста загрузки человекочитаемое отображаемое имя и передавать +его в qBittorrent (параметр `rename` API `/torrents/add`), чтобы задача в списке +qBit не показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL +**сохранять у загрузки** (`download.display_name`) для последующего показа +заголовком в веб-UI. Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и год; для сериала — номер сезона, если он определён), а не куском сырого контекста. Имя SHALL очищаться от управляющих символов и переводов строк и SHALL обрезаться по ограничению длины. -Вывод имени SHALL выполняться синхронно перед отдачей источника в -qBittorrent (параметр `rename` действует только в момент добавления). +Вывод имени SHALL выполняться на шаге добавления, непосредственно перед вызовом +`add` (параметр `rename` действует только в момент добавления), а НЕ в +синхронном пути ответа приёма. В состоянии `catched` (до добавления) +`download.display_name` ещё пуст — веб-UI берёт заголовок из фолбека. Отображаемое имя SHALL влиять только на отображение (в qBittorrent и как заголовок в веб-UI) и SHALL NOT влиять на пути файлов на диске, распознавание @@ -32,17 +34,17 @@ qBittorrent (параметр `rename` действует только в мом #### Scenario: Имя из контекста передаётся в qBittorrent -- **WHEN** загрузку добавляют с непустым контекстом, из которого удалось - получить имя +- **WHEN** на шаге добавления получен непустой контекст, из которого удалось + вывести имя - **THEN** система передаёт это имя в qBittorrent в параметре `rename` - **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)», где режиссёр и год опциональны #### Scenario: Имя сохраняется у загрузки -- **WHEN** при приёме получено непустое отображаемое имя -- **THEN** система сохраняет его в `download.display_name` вместе с созданием - загрузки +- **WHEN** на шаге добавления выведено непустое отображаемое имя +- **THEN** система сохраняет его в `download.display_name` (обновлением записи + загрузки) - **AND** веб-UI использует его заголовком карточки и страницы загрузки #### Scenario: Контекст пуст или имя не получено @@ -111,29 +113,37 @@ JSON-вывод), извлекая из контекста тип (movie/series) ### Requirement: Приём источника и заведение загрузки -Приём SHALL быть единым use-case, общим для всех транспортов (HTTP, Telegram, -CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL извлечь -инфохэши, дедуплицировать по активной задаче, при отсутствии дубля завести -загрузку (`download` в состоянии `downloading` + записи `download_infohash`) и -отдать источник в qBittorrent (категория `qbittorrent.category`, savepath). Если -добавление в qBittorrent не удалось, система SHALL перевести уже заведённую -загрузку в `failed` (`error_code` `qbit_add`) и уведомить автора. Заведение -загрузки и запись её хешей SHALL выполняться атомарно (см. «Атомарность возврата -загрузки в активное состояние»). +Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP, +Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL +синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети), +дедуплицировать по активной задаче и при отсутствии дубля завести загрузку +(`download` в состоянии **`catched`** + записи `download_infohash`), после чего +**сразу вернуть ответ** транспорту. Заведение загрузки и запись её хешей SHALL +выполняться атомарно (см. «Атомарность возврата загрузки в активное +состояние»). -#### Scenario: Успешный приём magnet +Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить +отображаемое имя (потенциально медленный LLM): и добавление источника в +qBittorrent, и вывод имени выполняются отдельным асинхронным шагом машины +состояний (worker) — см. `download-tracking` «Добавление пойманной загрузки в +qBittorrent». + +`catched` — нетерминальное активное состояние: оно участвует в инварианте «не +более одной активной загрузки на infohash» наравне с прочими активными. + +#### Scenario: Быстрый приём magnet - **GIVEN** валидная magnet-ссылка и контекст - **WHEN** вызывается приём -- **THEN** создаётся `download` в `downloading` с записями `download_infohash` -- **AND** источник отдан в qBittorrent с нашей категорией +- **THEN** создаётся `download` в состоянии `catched` с записями + `download_infohash` +- **AND** ответ транспорту отдан без обращения к qBittorrent и без вывода имени -#### Scenario: Падение добавления в qBittorrent +#### Scenario: Дубль по активной задаче на быстром пути -- **GIVEN** заведённую загрузку не удалось добавить в qBittorrent -- **WHEN** обрабатывается ошибка добавления -- **THEN** загрузка переходит в `failed` с `error_code` `qbit_add` -- **AND** автор загрузки уведомляется +- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash +- **WHEN** вызывается приём +- **THEN** новая загрузка не создаётся, возвращается существующая ### Requirement: Множество инфохэшей загрузки diff --git a/openspec/specs/web-ui/spec.md b/openspec/specs/web-ui/spec.md index 9865403..80a2e74 100644 --- a/openspec/specs/web-ui/spec.md +++ b/openspec/specs/web-ui/spec.md @@ -432,3 +432,44 @@ PRG-редиректом, и действие исполняется тем же - **THEN** карточка подменяется на месте новым состоянием и остаётся видимой до следующей полной загрузки списка, без клиентского переупорядочивания +### Requirement: Отображение промежуточного состояния catched + +Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу +(«поймано, добавляется в qBittorrent»): бейдж статуса загрузки SHALL иметь +понятную человекочитаемую подпись для `catched` (а не сырое `catched`), а +загрузка в `catched` SHALL относиться к **активной** группе списка. + +Пока отображаемое имя ещё не выведено (в `catched` `download.display_name` +пуст), заголовок загрузки SHALL деградировать по существующему фолбеку +(распознанное название или усечённый источник) — см. «Заголовок загрузки из +имени раздачи». Секция раздачи/живого прогресса для `catched` SHALL корректно +отсутствовать (раздачи в qBittorrent ещё нет), не создавая ошибок отображения. + +Карточка/страница загрузки в `catched` SHALL самообновляться самозавершающимся +htmx-поллингом (см. конвенцию веб-UI): по переходе загрузки в `downloading` +интерфейс SHALL отражать это без перезагрузки страницы (подхватить бейдж, +выведенное имя и появившийся живой прогресс), а поллинг фазы `catched` SHALL +завершаться, как только загрузка её покинула. + +#### Scenario: Бейдж и группа для catched + +- **WHEN** загрузка находится в состоянии `catched` +- **THEN** её бейдж статуса имеет человекочитаемую подпись для `catched` +- **AND** загрузка попадает в активную группу списка + +#### Scenario: Заголовок catched без имени + +- **GIVEN** загрузка в `catched` с пустым `download.display_name` +- **WHEN** рендерится карточка/страница загрузки +- **THEN** заголовок берётся из фолбека (распознанное название или усечённый + источник), без ошибок отображения +- **AND** секция раздачи/живого прогресса не показывается (раздачи ещё нет) + +#### Scenario: Самообновление при переходе в downloading + +- **GIVEN** открытая карточка загрузки в `catched` +- **WHEN** worker перевёл загрузку в `downloading` +- **THEN** интерфейс без перезагрузки показывает состояние `downloading` + (бейдж, имя, живой прогресс) +- **AND** поллинг фазы `catched` завершается + diff --git a/web/templates/partials/card.html b/web/templates/partials/card.html index 4519d20..a562bcf 100644 --- a/web/templates/partials/card.html +++ b/web/templates/partials/card.html @@ -1,5 +1,5 @@ {{define "card"}} -
+
diff --git a/web/templates/partials/download_main.html b/web/templates/partials/download_main.html index c12baef..5b9606e 100644 --- a/web/templates/partials/download_main.html +++ b/web/templates/partials/download_main.html @@ -1,5 +1,5 @@ {{define "download_main"}} -
+
← ко всем загрузкам {{if .Error}}

{{.Error}}

{{end}}