Добавил распознавание файлов для проверки
This commit is contained in:
@@ -2,9 +2,10 @@
|
||||
//
|
||||
// Подкоманды:
|
||||
//
|
||||
// jellybit [serve] --config <path> запустить сервис (по умолчанию)
|
||||
// jellybit add <magnet> [--context] добавить загрузку через REST API сервиса
|
||||
// jellybit healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
|
||||
// jellybit [serve] --config <path> запустить сервис (по умолчанию)
|
||||
// jellybit add <magnet> [--context] добавить загрузку через REST API сервиса
|
||||
// jellybit recognize <infohash> --dry-run показать план распознавания (без записи)
|
||||
// jellybit healthcheck --config <p> проверить /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:
|
||||
|
||||
@@ -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 останавливается на первом позиционном — допарсим флаги, стоящие
|
||||
// после <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 работает только в режиме --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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user