diff --git a/README.md b/README.md index c98fa00..a4bc3ba 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,16 @@ task build # статический бинарь (linu task image # docker-образ из готового бинаря ``` +Отладка распознавания на реальной раздаче (только чтение, без раскладки): + +```bash +jellybit recognize --dry-run [--context "..."] --config ./config.toml +``` + +Берёт торрент из qBittorrent по infohash, прогоняет распознавание (LLM + +метабазы) и печатает план: тип/название/год, матч в базе, решение авто/review +и превью целевых путей — то, что создалось бы при Apply. + ## Доставка Сборка здесь → готовый бинарь копируется на медиа-сервер umbar diff --git a/cmd/jellybit/main.go b/cmd/jellybit/main.go index 24008eb..57da01a 100644 --- a/cmd/jellybit/main.go +++ b/cmd/jellybit/main.go @@ -2,9 +2,10 @@ // // Подкоманды: // -// jellybit [serve] --config запустить сервис (по умолчанию) -// jellybit add [--context] добавить загрузку через REST API сервиса -// jellybit healthcheck --config

проверить /healthz (для docker HEALTHCHECK) +// jellybit [serve] --config запустить сервис (по умолчанию) +// jellybit add [--context] добавить загрузку через REST API сервиса +// jellybit recognize --dry-run показать план распознавания (без записи) +// jellybit healthcheck --config

проверить /healthz (для docker HEALTHCHECK) package main import ( @@ -28,6 +29,8 @@ func main() { err = runServe(args) case "add": err = runAdd(args) + case "recognize": + err = runRecognize(args) case "healthcheck": err = runHealthcheck(args) default: diff --git a/cmd/jellybit/recognize.go b/cmd/jellybit/recognize.go new file mode 100644 index 0000000..4715649 --- /dev/null +++ b/cmd/jellybit/recognize.go @@ -0,0 +1,202 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "strings" + "time" + + "git.vakhrushev.me/av/jellybit/internal/config" + "git.vakhrushev.me/av/jellybit/internal/layout" + "git.vakhrushev.me/av/jellybit/internal/llm" + "git.vakhrushev.me/av/jellybit/internal/metadata" + "git.vakhrushev.me/av/jellybit/internal/qbt" + "git.vakhrushev.me/av/jellybit/internal/recognize" + "git.vakhrushev.me/av/jellybit/internal/worker" +) + +// runRecognize — диагностика распознавания: берёт торрент из qBittorrent по +// infohash, прогоняет конвейер (LLM + метабазы) и печатает план раскладки. +// Только чтение: ни записи в БД, ни хардлинков. +func runRecognize(args []string) error { + fs := flag.NewFlagSet("recognize", flag.ContinueOnError) + configPath := fs.String("config", "/data/config.toml", "путь к config.toml") + dryRun := fs.Bool("dry-run", true, "только показать план, без изменений (единственный режим)") + contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания") + if err := fs.Parse(args); err != nil { + return err + } + infohash := strings.ToLower(strings.TrimSpace(fs.Arg(0))) + // flag останавливается на первом позиционном — допарсим флаги, стоящие + // после (напр. `recognize --dry-run`). + if fs.NArg() > 1 { + if err := fs.Parse(fs.Args()[1:]); err != nil { + return err + } + } + if infohash == "" { + return fmt.Errorf("usage: jellybit recognize [--dry-run] [--context ...]") + } + if !*dryRun { + return fmt.Errorf("recognize работает только в режиме --dry-run; раскладка — через ревью") + } + + cfg, err := config.Load(*configPath) + if err != nil { + return err + } + if cfg.LLM.Type == "" || cfg.LLM.BaseURL == "" { + return fmt.Errorf("в конфиге не настроен [llm] — распознавание невозможно") + } + // Внутренние логи (ретраи/ошибки провайдеров) — в stderr, чтобы не мешать + // плану в stdout. + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + // qBittorrent: ищем торрент и его файлы. + qb, err := qbt.New(qbt.Config{ + URL: cfg.QBittorrent.URL, + Username: cfg.QBittorrent.Username, + Password: cfg.QBittorrent.Password, + }) + if err != nil { + return err + } + torrents, err := qb.Torrents(ctx, "") + if err != nil { + return fmt.Errorf("qbittorrent: %w", err) + } + t, ok := findTorrent(torrents, infohash) + if !ok { + return fmt.Errorf("торрент с infohash %s не найден в qBittorrent", infohash) + } + files, err := qb.Files(ctx, t.Hash) + if err != nil { + return fmt.Errorf("qbittorrent files: %w", err) + } + + // Провайдеры метаданных + LLM + распознаватель. + providers, err := metadataProviders(cfg) + if err != nil { + return err + } + provider, err := llm.New(llm.Config{ + Type: cfg.LLM.Type, BaseURL: cfg.LLM.BaseURL, APIKey: cfg.LLM.APIKey, + Model: cfg.LLM.Model, Proxy: cfg.LLM.Proxy, Timeout: cfg.LLM.Timeout.Std(), + }) + if err != nil { + return err + } + rec := recognize.New(provider, providers, recognize.Config{ + MaxRetries: cfg.LLM.MaxRetries, + AutoThreshold: cfg.Recognition.AutoConfidenceThreshold, + }, logger) + + in := recognize.Input{Name: t.Name, Context: *contextStr} + for _, f := range files { + in.Files = append(in.Files, recognize.File{Path: f.Name, Size: f.Size}) + } + + start := time.Now() + res, err := rec.Recognize(ctx, in) + if err != nil { + return fmt.Errorf("распознавание: %w", err) + } + + // Раскладчик для превью (BuildLinks ничего не пишет). + lay, err := layout.New(layout.Config{MoviesDir: cfg.Paths.Movies, SeriesDir: cfg.Paths.Series}) + if err != nil { + return err + } + printDryRun(t, files, res, lay, providerNames(providers), time.Since(start)) + return nil +} + +func findTorrent(torrents []qbt.Torrent, infohash string) (qbt.Torrent, bool) { + for _, t := range torrents { + for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} { + if h != "" && strings.EqualFold(h, infohash) { + return t, true + } + } + } + return qbt.Torrent{}, false +} + +func printDryRun(t qbt.Torrent, files []qbt.File, res recognize.Result, lay *layout.Layouter, providers []string, took time.Duration) { + p := res.Plan + fmt.Printf("════ Торрент ════\n") + fmt.Printf("name : %s\n", t.Name) + fmt.Printf("infohash : %s\n", t.Hash) + fmt.Printf("save_path : %s\n", t.SavePath) + fmt.Printf("файлов : %d state: %s\n\n", len(files), t.State) + + fmt.Printf("════ Распознавание ════\n") + fmt.Printf("провайдеры базы: %v\n", providers) + fmt.Printf("заняло : %s, попыток LLM: %d\n", took.Truncate(time.Millisecond), res.Attempts) + fmt.Printf("тип : %s\n", p.Type) + fmt.Printf("название : %s", p.Title) + if p.OriginalTitle != "" { + fmt.Printf(" (ориг: %s)", p.OriginalTitle) + } + fmt.Printf("\nгод : %d\n", p.Year) + fmt.Printf("self-confidence: %.2f\n", p.Confidence) + if p.Notes != "" { + fmt.Printf("notes : %s\n", p.Notes) + } + + fmt.Printf("\n──── Матч в базе ────\n") + if m := res.Match; m != nil { + fmt.Printf("provider=%s id=%s title=%q year=%d\n", m.Provider, m.ProviderID, m.Title, m.Year) + if len(m.SeasonEpisodeCounts) > 0 { + fmt.Printf("серий по сезонам в базе: %v\n", m.SeasonEpisodeCounts) + } + } else { + fmt.Printf("единичного сильного матча нет\n") + } + if len(res.Candidates) > 0 { + fmt.Printf("кандидаты для ручного выбора (%d):\n", len(res.Candidates)) + for _, c := range res.Candidates { + tagP, tagID := recognize.CandidateTag(c) + fmt.Printf(" · %s/%s %q (%d) [тег: %s-%s]\n", c.Provider, c.ID, c.Title, c.Year, tagP, tagID) + } + } + + fmt.Printf("\n──── Решение ────\n") + if res.Decision.Auto { + fmt.Printf("АВТО-раскладка (review не нужен)\n") + } else { + fmt.Printf("REVIEW — причины:\n") + for _, reason := range res.Decision.Reasons { + fmt.Printf(" · %s\n", reason) + } + } + + fmt.Printf("\n──── Превью раскладки (хардлинки НЕ создаются) ────\n") + tag := "" + if res.Match != nil { + tag = worker.ProviderTag(res.Match.Provider, res.Match.ProviderID) + } + links, err := lay.BuildLinks(worker.ToLayoutPlan(p, t.SavePath, tag)) + if err != nil { + fmt.Printf("план не построился: %v\n", err) + return + } + for _, l := range links { + fmt.Printf(" [%s] %s\n", l.Kind, l.Dst) + } + fmt.Printf("\nИтого ссылок: %d (это создалось бы при Apply)\n", len(links)) +} + +func providerNames(providers []metadata.Provider) []string { + out := make([]string, len(providers)) + for i, p := range providers { + out[i] = p.Name() + } + return out +} diff --git a/cmd/jellybit/recognize_test.go b/cmd/jellybit/recognize_test.go new file mode 100644 index 0000000..cd3434f --- /dev/null +++ b/cmd/jellybit/recognize_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "strings" + "testing" +) + +func TestRecognize_RequiresInfohash(t *testing.T) { + if err := runRecognize(nil); err == nil || !strings.Contains(err.Error(), "usage") { + t.Errorf("без infohash ожидалась usage-ошибка, got %v", err) + } +} + +func TestRecognize_DryRunOnly(t *testing.T) { + // Флаг после позиционного должен разобраться (допарсинг), а --dry-run=false + // — отклониться до обращения к конфигу/сети. + err := runRecognize([]string{"abc123", "--dry-run=false"}) + if err == nil || !strings.Contains(err.Error(), "dry-run") { + t.Errorf("ожидалась ошибка про dry-run, got %v", err) + } +} diff --git a/internal/worker/review.go b/internal/worker/review.go index 08bb7e9..c5ece08 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -659,6 +659,15 @@ func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store. return out } +// ProviderTag — экспорт providerTag для диагностических команд (CLI +// `jellybit recognize --dry-run`). +func ProviderTag(provider, id string) string { return providerTag(provider, id) } + +// ToLayoutPlan — экспорт toLayoutPlan для диагностических команд. +func ToLayoutPlan(p recognize.Plan, srcPrefix, providerTag string) layout.Plan { + return toLayoutPlan(p, srcPrefix, providerTag) +} + // providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…" // / "tvdbid-…". Пустой id (нет матча) → пустой тег. func providerTag(provider, id string) string {