Добавил распознавание файлов для проверки

This commit is contained in:
2026-06-14 17:21:15 +03:00
parent 4e077d878e
commit 2dbbb1b706
5 changed files with 248 additions and 3 deletions
+10
View File
@@ -67,6 +67,16 @@ task build # статический бинарь (linu
task image # docker-образ из готового бинаря task image # docker-образ из готового бинаря
``` ```
Отладка распознавания на реальной раздаче (только чтение, без раскладки):
```bash
jellybit recognize <infohash> --dry-run [--context "..."] --config ./config.toml
```
Берёт торрент из qBittorrent по infohash, прогоняет распознавание (LLM +
метабазы) и печатает план: тип/название/год, матч в базе, решение авто/review
и превью целевых путей — то, что создалось бы при Apply.
## Доставка ## Доставка
Сборка здесь → готовый бинарь копируется на медиа-сервер umbar Сборка здесь → готовый бинарь копируется на медиа-сервер umbar
+3
View File
@@ -4,6 +4,7 @@
// //
// jellybit [serve] --config <path> запустить сервис (по умолчанию) // jellybit [serve] --config <path> запустить сервис (по умолчанию)
// jellybit add <magnet> [--context] добавить загрузку через REST API сервиса // jellybit add <magnet> [--context] добавить загрузку через REST API сервиса
// jellybit recognize <infohash> --dry-run показать план распознавания (без записи)
// jellybit healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK) // jellybit healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
package main package main
@@ -28,6 +29,8 @@ func main() {
err = runServe(args) err = runServe(args)
case "add": case "add":
err = runAdd(args) err = runAdd(args)
case "recognize":
err = runRecognize(args)
case "healthcheck": case "healthcheck":
err = runHealthcheck(args) err = runHealthcheck(args)
default: default:
+202
View File
@@ -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
}
+21
View File
@@ -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)
}
}
+9
View File
@@ -659,6 +659,15 @@ func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store.
return out 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-…" // providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…"
// / "tvdbid-…". Пустой id (нет матча) → пустой тег. // / "tvdbid-…". Пустой id (нет матча) → пустой тег.
func providerTag(provider, id string) string { func providerTag(provider, id string) string {