- новое поле [general].language (ru|en, дефолт en) — единый источник языка локализованного title детектора и локали запросов к метабазам; original_title всегда на языке оригинала - локаль TMDB (поиск + credits) выводится из него, [metadata.tmdb].language убран - промпт LLM явно задаёт язык title с fallback на оригинал
204 lines
7.1 KiB
Go
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
|
|
}
|