Files
jellybit/cmd/jellybit/recognize.go
T
av a2d8140e22 recognition: глобальный переключатель языка вывода [general].language
- новое поле [general].language (ru|en, дефолт en) — единый источник языка
  локализованного title детектора и локали запросов к метабазам; original_title
  всегда на языке оригинала
- локаль TMDB (поиск + credits) выводится из него, [metadata.tmdb].language убран
- промпт LLM явно задаёт язык title с fallback на оригинал
2026-07-24 14:09:04 +03:00

204 lines
7.1 KiB
Go

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", config.DefaultPath, "путь к 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 останавливается на первом позиционном — допарсим флаги, стоящие
// после <infohash> (напр. `recognize <infohash> --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 <infohash> [--dry-run] [--context ...]")
}
if !*dryRun {
return fmt.Errorf("recognize runs only in --dry-run mode; layout is applied via review")
}
cfg, err := config.Load(*configPath)
if err != nil {
return err
}
if cfg.LLM.Type == "" || cfg.LLM.BaseURL == "" {
return fmt.Errorf("[llm] is not configured in config — recognition is unavailable")
}
// Внутренние логи (ретраи/ошибки провайдеров) — в 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,
}, logger)
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("torrent with infohash %s not found in qBittorrent", infohash)
}
files, err := qb.Files(ctx, t.Hash)
if err != nil {
return fmt.Errorf("qbittorrent files: %w", err)
}
// Провайдеры метаданных + LLM + распознаватель.
providers, err := metadataProviders(cfg, logger)
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(),
}, logger)
if err != nil {
return err
}
rec := recognize.New(provider, providers, recognize.Config{
MaxRetries: cfg.LLM.MaxRetries,
AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
Language: cfg.ContentLanguage(),
}, 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("recognize: %w", err)
}
// Раскладчик для превью (BuildLinks ничего не пишет; логгер не нужен).
lay, err := layout.New(layout.Config{MoviesDir: cfg.Paths.Movies, SeriesDir: cfg.Paths.Series}, nil)
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
}