@@ -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
}