diff --git a/.gitignore b/.gitignore index 8e1eece..6dfdb09 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Реальный конфиг (секреты) и локальная БД /config.toml +/.env *.db *.db-wal *.db-shm diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index b1f43af..3d52cad 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -16,6 +16,7 @@ import ( "git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/llm" "git.vakhrushev.me/av/jellybit/internal/logging" + "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/store" @@ -60,6 +61,15 @@ func runServe(args []string) error { SavePath: cfg.QBittorrent.SavePath, }, logger) + // Ф4: базы метаданных (опц.). Без них авто-раскладки нет — всё в review. + providers, err := metadataProviders(cfg) + if err != nil { + return err + } + for _, p := range providers { + logger.Info("metadata provider enabled", "provider", p.Name()) + } + // Ф2/Ф3: распознаватель и раскладчик. Если LLM не сконфигурирован, // сервис работает как в Ф1 (completed-задачи дальше не двигаются). var recognizer worker.Recognizer @@ -75,8 +85,11 @@ func runServe(args []string) error { if perr != nil { return fmt.Errorf("llm provider: %w", perr) } - recognizer = recognize.New(provider, recognize.Config{MaxRetries: cfg.LLM.MaxRetries}, logger) - logger.Info("recognizer ready", "model", cfg.LLM.Model) + recognizer = recognize.New(provider, providers, recognize.Config{ + MaxRetries: cfg.LLM.MaxRetries, + AutoThreshold: cfg.Recognition.AutoConfidenceThreshold, + }, logger) + logger.Info("recognizer ready", "model", cfg.LLM.Model, "providers", len(providers)) } else { logger.Warn("llm not configured, recognition disabled") } @@ -142,3 +155,32 @@ func runServe(args []string) error { logger.Info("stopped") return nil } + +// metadataProviders собирает включённые конфигом базы метаданных. Для +// сериалов Jellyfin привычнее tvdbid, поэтому TVDB идёт первым. +func metadataProviders(cfg *config.Config) ([]metadata.Provider, error) { + var out []metadata.Provider + if cfg.Metadata.TVDB.Enabled { + p, err := metadata.NewTVDB(metadata.TVDBConfig{ + APIKey: cfg.Metadata.TVDB.APIKey, + Proxy: cfg.Metadata.TVDB.Proxy, + Timeout: cfg.Metadata.TVDB.Timeout.Std(), + }) + if err != nil { + return nil, fmt.Errorf("tvdb provider: %w", err) + } + out = append(out, p) + } + if cfg.Metadata.TMDB.Enabled { + p, err := metadata.NewTMDB(metadata.TMDBConfig{ + APIKey: cfg.Metadata.TMDB.APIKey, + Proxy: cfg.Metadata.TMDB.Proxy, + Timeout: cfg.Metadata.TMDB.Timeout.Std(), + }) + if err != nil { + return nil, fmt.Errorf("tmdb provider: %w", err) + } + out = append(out, p) + } + return out, nil +} diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index b4581a3..f7a6728 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -36,6 +36,8 @@ type reviewView struct { Title string OriginalTitle string Year int + Provider string + ProviderID string Confidence string Reasons []string Hints []string @@ -85,6 +87,10 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) { view.OriginalTitle = rd.Plan.OriginalTitle view.Year = rd.Plan.Year view.Reasons = rec.ReasonList() + if rec.Provider.Valid && rec.Provider.String != "none" { + view.Provider = rec.Provider.String + view.ProviderID = rec.ProviderID.String + } if rec.Confidence.Valid { view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64) } diff --git a/internal/metadata/doc.go b/internal/metadata/doc.go deleted file mode 100644 index 9c1c39a..0000000 --- a/internal/metadata/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// Package metadata — интерфейс баз метаданных и клиенты TMDB/TVDB (опц.). -// -// Заглушка: реализация в фазе Ф4 (см. docs/specs/architecture.md). -package metadata diff --git a/internal/metadata/http.go b/internal/metadata/http.go new file mode 100644 index 0000000..8561071 --- /dev/null +++ b/internal/metadata/http.go @@ -0,0 +1,89 @@ +package metadata + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const defaultTimeout = 10 * time.Second + +// newHTTPClient собирает http.Client с опциональным прокси и таймаутом. +func newHTTPClient(proxy string, timeout time.Duration) (*http.Client, error) { + if timeout <= 0 { + timeout = defaultTimeout + } + transport := http.DefaultTransport + if proxy != "" { + u, err := url.Parse(proxy) + if err != nil { + return nil, fmt.Errorf("metadata: parse proxy %q: %w", proxy, err) + } + transport = &http.Transport{Proxy: http.ProxyURL(u)} + } + return &http.Client{Timeout: timeout, Transport: transport}, nil +} + +const maxBody = 4 << 20 // 4 MiB — потолок на тело ответа + +// getJSON выполняет GET и декодирует JSON-ответ в out. headers — опц. +// дополнительные заголовки (напр. Authorization). +func getJSON(ctx context.Context, hc *http.Client, rawURL string, headers map[string]string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return fmt.Errorf("metadata: build request: %w", err) + } + req.Header.Set("Accept", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + return doJSON(hc, req, out) +} + +// postJSON выполняет POST с JSON-телом и декодирует ответ. +func postJSON(ctx context.Context, hc *http.Client, rawURL string, body, out any) error { + payload, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("metadata: marshal body: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("metadata: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + return doJSON(hc, req, out) +} + +func doJSON(hc *http.Client, req *http.Request, out any) error { + resp, err := hc.Do(req) + if err != nil { + return fmt.Errorf("metadata: request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + if err != nil { + return fmt.Errorf("metadata: read body: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("metadata: status %d: %s", resp.StatusCode, snippet(raw)) + } + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("metadata: decode: %w (body: %s)", err, snippet(raw)) + } + return nil +} + +func snippet(b []byte) string { + const max = 200 + if len(b) > max { + return string(b[:max]) + "…" + } + return string(b) +} diff --git a/internal/metadata/integration_test.go b/internal/metadata/integration_test.go new file mode 100644 index 0000000..8ae0052 --- /dev/null +++ b/internal/metadata/integration_test.go @@ -0,0 +1,53 @@ +package metadata_test + +import ( + "context" + "os" + "testing" + "time" + + "git.vakhrushev.me/av/jellybit/internal/metadata" +) + +// TestIntegration_TVDB бьётся в реальный TheTVDB v4. По умолчанию +// пропускается; включается ключом: +// +// TVDB_API_KEY=... go test ./internal/metadata/ -run Integration -v +func TestIntegration_TVDB(t *testing.T) { + key := os.Getenv("TVDB_API_KEY") + if key == "" { + t.Skip("set TVDB_API_KEY to run") + } + c, err := metadata.NewTVDB(metadata.TVDBConfig{APIKey: key, Timeout: 20 * time.Second}) + if err != nil { + t.Fatalf("NewTVDB: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cands, err := c.Search(ctx, metadata.Query{Type: metadata.Series, Title: "Fargo", Year: 2014}) + if err != nil { + t.Fatalf("Search: %v", err) + } + t.Logf("candidates (%d):", len(cands)) + for i, cd := range cands { + if i >= 5 { + break + } + t.Logf(" id=%s title=%q year=%d", cd.ID, cd.Title, cd.Year) + } + if len(cands) == 0 { + t.Fatal("ожидался хотя бы один кандидат для Fargo") + } + + // Берём первого с непустым id и тянем число серий по сезонам. + id := cands[0].ID + counts, err := c.SeasonEpisodeCounts(ctx, id) + if err != nil { + t.Fatalf("SeasonEpisodeCounts(%s): %v", id, err) + } + t.Logf("season episode counts for id=%s: %v", id, counts) + if len(counts) == 0 { + t.Error("ожидались данные о числе серий по сезонам") + } +} diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go new file mode 100644 index 0000000..61913c4 --- /dev/null +++ b/internal/metadata/metadata.go @@ -0,0 +1,45 @@ +// Package metadata — интерфейс баз метаданных и клиенты TMDB/TVDB. +// +// Базы опциональны (включаются конфигом). Их роль — подтвердить распознавание +// официальным id и каноническим именем: при единичном сильном матче по +// названию+году раскладка делается автоматически, иначе уходит в review +// (см. docs/specs/recognition.md → «Модель уверенности»). Каждый клиент +// ходит наружу через опциональный HTTP-прокси с таймаутом. +package metadata + +import "context" + +// MediaType — вид контента в запросе к базе. +type MediaType string + +const ( + Movie MediaType = "movie" + Series MediaType = "series" +) + +// Query — запрос поиска в базе. +type Query struct { + Type MediaType + Title string // каноническое название или provider_hint + Year int // 0 — без ограничения по году +} + +// Candidate — результат поиска: официальный id и каноническое имя. +type Candidate struct { + Provider string // "tmdb" | "tvdb" + ID string + Title string + OriginalTitle string + Year int +} + +// Provider — одна база метаданных. +type Provider interface { + // Name — идентификатор провайдера ("tmdb"/"tvdb"), он же префикс тега. + Name() string + // Search ищет кандидатов по названию (и году, если задан). + Search(ctx context.Context, q Query) ([]Candidate, error) + // SeasonEpisodeCounts возвращает число серий по сезонам для сериала + // (ключ — номер сезона). Нужен для валидации полноты сезон-пака. + SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) +} diff --git a/internal/metadata/tmdb.go b/internal/metadata/tmdb.go new file mode 100644 index 0000000..3a9f37b --- /dev/null +++ b/internal/metadata/tmdb.go @@ -0,0 +1,132 @@ +package metadata + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const tmdbDefaultBaseURL = "https://api.themoviedb.org/3" + +// TMDBConfig — настройки клиента TMDB. +type TMDBConfig struct { + APIKey string + Proxy string + Timeout time.Duration + BaseURL string // пусто → api.themoviedb.org; задаётся в тестах +} + +// TMDB — клиент The Movie Database (API v3, авторизация по api_key). +type TMDB struct { + apiKey string + baseURL string + hc *http.Client +} + +// NewTMDB собирает клиент TMDB. +func NewTMDB(cfg TMDBConfig) (*TMDB, error) { + if cfg.APIKey == "" { + return nil, fmt.Errorf("metadata: tmdb api_key required") + } + hc, err := newHTTPClient(cfg.Proxy, cfg.Timeout) + if err != nil { + return nil, err + } + base := cfg.BaseURL + if base == "" { + base = tmdbDefaultBaseURL + } + return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc}, nil +} + +func (t *TMDB) Name() string { return "tmdb" } + +type tmdbSearchResp struct { + Results []struct { + ID int `json:"id"` + Title string `json:"title"` // movie + OriginalTitle string `json:"original_title"` // movie + Name string `json:"name"` // tv + OriginalName string `json:"original_name"` // tv + ReleaseDate string `json:"release_date"` // movie + FirstAirDate string `json:"first_air_date"` // tv + } `json:"results"` +} + +// Search ищет фильм/сериал по названию и году. +func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) { + var path string + params := url.Values{"api_key": {t.apiKey}, "query": {q.Title}, "include_adult": {"false"}} + switch q.Type { + case Movie: + path = "/search/movie" + if q.Year > 0 { + params.Set("year", strconv.Itoa(q.Year)) + } + case Series: + path = "/search/tv" + if q.Year > 0 { + params.Set("first_air_date_year", strconv.Itoa(q.Year)) + } + default: + return nil, fmt.Errorf("metadata: tmdb: неизвестный тип %q", q.Type) + } + + var resp tmdbSearchResp + if err := getJSON(ctx, t.hc, t.baseURL+path+"?"+params.Encode(), nil, &resp); err != nil { + return nil, fmt.Errorf("tmdb search: %w", err) + } + + out := make([]Candidate, 0, len(resp.Results)) + for _, r := range resp.Results { + title, orig, date := r.Title, r.OriginalTitle, r.ReleaseDate + if q.Type == Series { + title, orig, date = r.Name, r.OriginalName, r.FirstAirDate + } + out = append(out, Candidate{ + Provider: "tmdb", + ID: strconv.Itoa(r.ID), + Title: title, + OriginalTitle: orig, + Year: yearOf(date), + }) + } + return out, nil +} + +type tmdbTVResp struct { + Seasons []struct { + SeasonNumber int `json:"season_number"` + EpisodeCount int `json:"episode_count"` + } `json:"seasons"` +} + +// SeasonEpisodeCounts возвращает число серий по сезонам сериала. +func (t *TMDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) { + params := url.Values{"api_key": {t.apiKey}} + var resp tmdbTVResp + if err := getJSON(ctx, t.hc, t.baseURL+"/tv/"+url.PathEscape(id)+"?"+params.Encode(), nil, &resp); err != nil { + return nil, fmt.Errorf("tmdb tv %s: %w", id, err) + } + out := make(map[int]int, len(resp.Seasons)) + for _, s := range resp.Seasons { + out[s.SeasonNumber] = s.EpisodeCount + } + return out, nil +} + +// yearOf достаёт год из даты вида "1999-03-31". +func yearOf(date string) int { + if len(date) < 4 { + return 0 + } + y, err := strconv.Atoi(date[:4]) + if err != nil { + return 0 + } + return y +} diff --git a/internal/metadata/tmdb_test.go b/internal/metadata/tmdb_test.go new file mode 100644 index 0000000..78e91af --- /dev/null +++ b/internal/metadata/tmdb_test.go @@ -0,0 +1,109 @@ +package metadata + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func newTMDB(t *testing.T, url string) *TMDB { + t.Helper() + c, err := NewTMDB(TMDBConfig{APIKey: "k", BaseURL: url}) + if err != nil { + t.Fatalf("NewTMDB: %v", err) + } + return c +} + +func TestTMDB_SearchMovie(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/search/movie" { + t.Errorf("path = %q", r.URL.Path) + } + q := r.URL.Query() + if q.Get("api_key") != "k" || q.Get("query") != "The Matrix" || q.Get("year") != "1999" { + t.Errorf("query = %v", q) + } + _, _ = w.Write([]byte(`{"results":[ + {"id":603,"title":"The Matrix","original_title":"The Matrix","release_date":"1999-03-31"} + ]}`)) + })) + defer srv.Close() + + got, err := newTMDB(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "The Matrix", Year: 1999}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(got) != 1 { + t.Fatalf("got %d candidates", len(got)) + } + c := got[0] + if c.Provider != "tmdb" || c.ID != "603" || c.Title != "The Matrix" || c.Year != 1999 { + t.Errorf("candidate = %+v", c) + } +} + +func TestTMDB_SearchSeries(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/search/tv" { + t.Errorf("path = %q", r.URL.Path) + } + if r.URL.Query().Get("first_air_date_year") != "2015" { + t.Errorf("year param = %q", r.URL.Query().Get("first_air_date_year")) + } + _, _ = w.Write([]byte(`{"results":[ + {"id":60622,"name":"Fargo","original_name":"Fargo","first_air_date":"2014-04-15"} + ]}`)) + })) + defer srv.Close() + + got, err := newTMDB(t, srv.URL).Search(context.Background(), Query{Type: Series, Title: "Fargo", Year: 2015}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(got) != 1 || got[0].ID != "60622" || got[0].Title != "Fargo" || got[0].Year != 2014 { + t.Errorf("candidate = %+v", got[0]) + } +} + +func TestTMDB_SeasonEpisodeCounts(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/tv/60622" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"seasons":[ + {"season_number":0,"episode_count":2}, + {"season_number":1,"episode_count":10}, + {"season_number":2,"episode_count":10} + ]}`)) + })) + defer srv.Close() + + counts, err := newTMDB(t, srv.URL).SeasonEpisodeCounts(context.Background(), "60622") + if err != nil { + t.Fatalf("SeasonEpisodeCounts: %v", err) + } + if counts[1] != 10 || counts[2] != 10 || counts[0] != 2 { + t.Errorf("counts = %v", counts) + } +} + +func TestTMDB_ErrorStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"status_message":"invalid key"}`)) + })) + defer srv.Close() + + _, err := newTMDB(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "X"}) + if err == nil { + t.Fatal("want error on 401") + } +} + +func TestNewTMDB_RequiresKey(t *testing.T) { + if _, err := NewTMDB(TMDBConfig{}); err == nil { + t.Fatal("want error without api_key") + } +} diff --git a/internal/metadata/tvdb.go b/internal/metadata/tvdb.go new file mode 100644 index 0000000..707e15a --- /dev/null +++ b/internal/metadata/tvdb.go @@ -0,0 +1,185 @@ +package metadata + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +const tvdbDefaultBaseURL = "https://api4.thetvdb.com/v4" + +// TVDBConfig — настройки клиента TheTVDB. +type TVDBConfig struct { + APIKey string + Proxy string + Timeout time.Duration + BaseURL string // пусто → api4.thetvdb.com; задаётся в тестах +} + +// TVDB — клиент TheTVDB (API v4). Токен получается логином по apikey и +// кэшируется; при 401 выполняется повторный логин. Формы ответов сверены с +// живым API v4 (см. integration_test.go). +type TVDB struct { + apiKey string + baseURL string + hc *http.Client + + mu sync.Mutex + token string +} + +// NewTVDB собирает клиент TVDB. +func NewTVDB(cfg TVDBConfig) (*TVDB, error) { + if cfg.APIKey == "" { + return nil, fmt.Errorf("metadata: tvdb api_key required") + } + hc, err := newHTTPClient(cfg.Proxy, cfg.Timeout) + if err != nil { + return nil, err + } + base := cfg.BaseURL + if base == "" { + base = tvdbDefaultBaseURL + } + return &TVDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc}, nil +} + +func (t *TVDB) Name() string { return "tvdb" } + +// login получает и кэширует bearer-токен. +func (t *TVDB) login(ctx context.Context) (string, error) { + t.mu.Lock() + defer t.mu.Unlock() + if t.token != "" { + return t.token, nil + } + var resp struct { + Data struct { + Token string `json:"token"` + } `json:"data"` + } + if err := postJSON(ctx, t.hc, t.baseURL+"/login", + map[string]string{"apikey": t.apiKey}, &resp); err != nil { + return "", fmt.Errorf("tvdb login: %w", err) + } + if resp.Data.Token == "" { + return "", fmt.Errorf("tvdb login: empty token") + } + t.token = resp.Data.Token + return t.token, nil +} + +// get делает авторизованный GET; при 401 один раз перелогинивается. +func (t *TVDB) get(ctx context.Context, path string, out any) error { + token, err := t.login(ctx) + if err != nil { + return err + } + status, raw, err := t.rawGet(ctx, path, token) + if err != nil { + return err + } + if status == http.StatusUnauthorized { + t.mu.Lock() + t.token = "" // сбрасываем протухший токен + t.mu.Unlock() + if token, err = t.login(ctx); err != nil { + return err + } + if status, raw, err = t.rawGet(ctx, path, token); err != nil { + return err + } + } + if status != http.StatusOK { + return fmt.Errorf("tvdb: status %d: %s", status, snippet(raw)) + } + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("tvdb: decode: %w (body: %s)", err, snippet(raw)) + } + return nil +} + +func (t *TVDB) rawGet(ctx context.Context, path, token string) (int, []byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.baseURL+path, nil) + if err != nil { + return 0, nil, fmt.Errorf("tvdb: build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + resp, err := t.hc.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("tvdb: request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + if err != nil { + return 0, nil, fmt.Errorf("tvdb: read body: %w", err) + } + return resp.StatusCode, raw, nil +} + +type tvdbSearchResp struct { + Data []struct { + TVDBID string `json:"tvdb_id"` + Name string `json:"name"` + Year string `json:"year"` + } `json:"data"` +} + +// Search ищет сериал/фильм по названию и году. +func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) { + typ := "series" + if q.Type == Movie { + typ = "movie" + } + params := url.Values{"query": {q.Title}, "type": {typ}} + if q.Year > 0 { + params.Set("year", strconv.Itoa(q.Year)) + } + var resp tvdbSearchResp + if err := t.get(ctx, "/search?"+params.Encode(), &resp); err != nil { + return nil, fmt.Errorf("tvdb search: %w", err) + } + out := make([]Candidate, 0, len(resp.Data)) + for _, r := range resp.Data { + if r.TVDBID == "" { + continue + } + year, _ := strconv.Atoi(r.Year) + out = append(out, Candidate{ + Provider: "tvdb", + ID: r.TVDBID, + Title: r.Name, + Year: year, + }) + } + return out, nil +} + +type tvdbExtendedResp struct { + Data struct { + Episodes []struct { + SeasonNumber int `json:"seasonNumber"` + } `json:"episodes"` + } `json:"data"` +} + +// SeasonEpisodeCounts считает число серий по сезонам из расширенных данных. +func (t *TVDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) { + var resp tvdbExtendedResp + if err := t.get(ctx, "/series/"+url.PathEscape(id)+"/extended?meta=episodes&short=true", &resp); err != nil { + return nil, fmt.Errorf("tvdb series %s: %w", id, err) + } + out := map[int]int{} + for _, e := range resp.Data.Episodes { + out[e.SeasonNumber]++ + } + return out, nil +} diff --git a/internal/metadata/tvdb_test.go b/internal/metadata/tvdb_test.go new file mode 100644 index 0000000..ef6c330 --- /dev/null +++ b/internal/metadata/tvdb_test.go @@ -0,0 +1,132 @@ +package metadata + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +// fakeTVDB — стенд v4: /login выдаёт токен, остальное требует Bearer. +func fakeTVDB(t *testing.T, logins *atomic.Int32) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if body["apikey"] != "k" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if logins != nil { + logins.Add(1) + } + _, _ = w.Write([]byte(`{"status":"success","data":{"token":"tok"}}`)) + }) + authed := func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tok" { + w.WriteHeader(http.StatusUnauthorized) + return + } + next(w, r) + } + } + mux.HandleFunc("/search", authed(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("type") != "series" || r.URL.Query().Get("query") != "Fargo" { + t.Errorf("query = %v", r.URL.Query()) + } + _, _ = w.Write([]byte(`{"data":[{"tvdb_id":"269613","name":"Fargo","year":"2014"}]}`)) + })) + mux.HandleFunc("/series/269613/extended", authed(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"episodes":[ + {"seasonNumber":1},{"seasonNumber":1},{"seasonNumber":2} + ]}}`)) + })) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func newTVDB(t *testing.T, url string) *TVDB { + t.Helper() + c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url}) + if err != nil { + t.Fatalf("NewTVDB: %v", err) + } + return c +} + +func TestTVDB_SearchAndLoginCached(t *testing.T) { + var logins atomic.Int32 + srv := fakeTVDB(t, &logins) + c := newTVDB(t, srv.URL) + + got, err := c.Search(context.Background(), Query{Type: Series, Title: "Fargo", Year: 2014}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(got) != 1 || got[0].ID != "269613" || got[0].Provider != "tvdb" || got[0].Year != 2014 { + t.Fatalf("candidate = %+v", got) + } + // Второй запрос переиспользует токен — повторного логина нет. + if _, err := c.Search(context.Background(), Query{Type: Series, Title: "Fargo"}); err != nil { + t.Fatal(err) + } + if logins.Load() != 1 { + t.Errorf("logins = %d, want 1 (token cached)", logins.Load()) + } +} + +func TestTVDB_SeasonEpisodeCounts(t *testing.T) { + srv := fakeTVDB(t, nil) + counts, err := newTVDB(t, srv.URL).SeasonEpisodeCounts(context.Background(), "269613") + if err != nil { + t.Fatalf("SeasonEpisodeCounts: %v", err) + } + if counts[1] != 2 || counts[2] != 1 { + t.Errorf("counts = %v", counts) + } +} + +func TestTVDB_ReloginOn401(t *testing.T) { + var logins atomic.Int32 + var token atomic.Value + token.Store("tok") + mux := http.NewServeMux() + mux.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) { + logins.Add(1) + _, _ = w.Write([]byte(`{"data":{"token":"tok"}}`)) + }) + var firstCall atomic.Bool + mux.HandleFunc("/search", func(w http.ResponseWriter, _ *http.Request) { + // Первый авторизованный запрос отдаёт 401 (токен «протух»). + if firstCall.CompareAndSwap(false, true) { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"data":[{"tvdb_id":"1","name":"X","year":"2000"}]}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := newTVDB(t, srv.URL) + got, err := c.Search(context.Background(), Query{Type: Series, Title: "X"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(got) != 1 { + t.Fatalf("got %d", len(got)) + } + if logins.Load() != 2 { + t.Errorf("logins = %d, want 2 (initial + relogin)", logins.Load()) + } +} + +func TestNewTVDB_RequiresKey(t *testing.T) { + if _, err := NewTVDB(TVDBConfig{}); err == nil { + t.Fatal("want error without api_key") + } +} diff --git a/internal/recognize/integration_test.go b/internal/recognize/integration_test.go index a694b23..7c896f5 100644 --- a/internal/recognize/integration_test.go +++ b/internal/recognize/integration_test.go @@ -44,7 +44,7 @@ func TestIntegration_RecognizeSeries(t *testing.T) { } log := slog.New(slog.NewTextHandler(io.Discard, nil)) - r := recognize.New(provider, recognize.Config{MaxRetries: 2}, log) + r := recognize.New(provider, nil, recognize.Config{MaxRetries: 2}, log) const dir = "Аватар Легенда об Аанге.Книга 2.Земля(Avatar The Last Airbender The book 2.Earth)/" in := recognize.Input{ diff --git a/internal/recognize/metadata.go b/internal/recognize/metadata.go new file mode 100644 index 0000000..486b4e7 --- /dev/null +++ b/internal/recognize/metadata.go @@ -0,0 +1,118 @@ +package recognize + +import ( + "context" + "strings" + "unicode" + + "git.vakhrushev.me/av/jellybit/internal/metadata" +) + +// matchMetadata сверяет план с включёнными базами: ищет по названию+году и, +// если ровно один кандидат уверенно совпадает (название и год), возвращает +// матч с официальным id и каноническим именем. Несколько кандидатов или их +// отсутствие → nil (тогда авто-раскладки не будет). Ошибки провайдера не +// валят распознавание — просто нет матча. +func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match { + if len(r.providers) == 0 { + return nil + } + mt := metadata.Movie + if plan.Type == MediaSeries { + mt = metadata.Series + } + + searchTitle := plan.ProviderHint + if strings.TrimSpace(searchTitle) == "" { + searchTitle = plan.Title + } + matchTitles := normSet(plan.Title, plan.OriginalTitle) + + for _, p := range r.providers { + cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year}) + if err != nil { + r.log.Warn("recognize: metadata search failed", "provider", p.Name(), "err", err) + continue + } + strong := strongMatches(cands, plan.Year, matchTitles) + if len(strong) != 1 { + continue + } + c := strong[0] + match := &Match{Provider: c.Provider, ProviderID: c.ID, Title: c.Title, Year: c.Year} + if mt == metadata.Series { + if counts, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil { + match.SeasonEpisodeCounts = counts + } else { + r.log.Warn("recognize: episode counts failed", + "provider", p.Name(), "id", c.ID, "err", cerr) + } + } + return match + } + return nil +} + +// strongMatches оставляет кандидатов, чьё название совпадает с одним из +// названий плана (после нормализации) и год бьётся (±1 год), дедуплицируя +// по id. +func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool) []metadata.Candidate { + seen := map[string]bool{} + var out []metadata.Candidate + for _, c := range cands { + if !yearMatches(year, c.Year) { + continue + } + if !titles[normalize(c.Title)] && !titles[normalize(c.OriginalTitle)] { + continue + } + if seen[c.ID] { + continue + } + seen[c.ID] = true + out = append(out, c) + } + return out +} + +// yearMatches: год известен у обоих и расходится не больше чем на 1 (разные +// базы по-разному датируют релиз), либо где-то год неизвестен. +func yearMatches(a, b int) bool { + if a == 0 || b == 0 { + return true + } + d := a - b + if d < 0 { + d = -d + } + return d <= 1 +} + +// normSet — множество нормализованных непустых названий. +func normSet(titles ...string) map[string]bool { + out := map[string]bool{} + for _, t := range titles { + if n := normalize(t); n != "" { + out[n] = true + } + } + return out +} + +// normalize приводит название к сравнимому виду: нижний регистр, только +// буквы/цифры (юникод), одиночные пробелы. +func normalize(s string) string { + var b strings.Builder + prevSpace := false + for _, r := range strings.ToLower(s) { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + b.WriteRune(r) + prevSpace = false + case !prevSpace: + b.WriteByte(' ') + prevSpace = true + } + } + return strings.TrimSpace(b.String()) +} diff --git a/internal/recognize/metadata_test.go b/internal/recognize/metadata_test.go new file mode 100644 index 0000000..35145ad --- /dev/null +++ b/internal/recognize/metadata_test.go @@ -0,0 +1,155 @@ +package recognize + +import ( + "context" + "errors" + "testing" + + "git.vakhrushev.me/av/jellybit/internal/metadata" +) + +type fakeProvider struct { + name string + candidates []metadata.Candidate + counts map[int]int + searchErr error + searched int +} + +func (f *fakeProvider) Name() string { + if f.name == "" { + return "tmdb" + } + return f.name +} +func (f *fakeProvider) Search(_ context.Context, _ metadata.Query) ([]metadata.Candidate, error) { + f.searched++ + return f.candidates, f.searchErr +} +func (f *fakeProvider) SeasonEpisodeCounts(_ context.Context, _ string) (map[int]int, error) { + return f.counts, nil +} + +func recognizerWith(p metadata.Provider) *Recognizer { + var providers []metadata.Provider + if p != nil { + providers = []metadata.Provider{p} + } + return New(&fakeLLM{}, providers, Config{}, testLogger()) +} + +func TestMatchMetadata_SingleStrong(t *testing.T) { + p := &fakeProvider{candidates: []metadata.Candidate{ + {Provider: "tmdb", ID: "603", Title: "The Matrix", Year: 1999}, + {Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003}, + }} + r := recognizerWith(p) + m := r.matchMetadata(context.Background(), + Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999}) + if m == nil { + t.Fatal("expected match") + } + if m.ProviderID != "603" || m.Provider != "tmdb" { + t.Errorf("match = %+v", m) + } +} + +func TestMatchMetadata_AmbiguousNoMatch(t *testing.T) { + // Два кандидата с тем же названием и годом — неоднозначно. + p := &fakeProvider{candidates: []metadata.Candidate{ + {ID: "1", Title: "Fargo", Year: 2014}, + {ID: "2", Title: "Fargo", Year: 2014}, + }} + r := recognizerWith(p) + if m := r.matchMetadata(context.Background(), + Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}); m != nil { + t.Errorf("ambiguous must not match, got %+v", m) + } +} + +func TestMatchMetadata_YearMismatch(t *testing.T) { + p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}} + r := recognizerWith(p) + if m := r.matchMetadata(context.Background(), + Plan{Type: MediaMovie, Title: "X", Year: 2020}); m != nil { + t.Errorf("year mismatch must not match, got %+v", m) + } +} + +func TestMatchMetadata_OriginalTitle(t *testing.T) { + p := &fakeProvider{candidates: []metadata.Candidate{ + {ID: "1", Title: "Leon", OriginalTitle: "Léon", Year: 1994}, + }} + r := recognizerWith(p) + m := r.matchMetadata(context.Background(), + Plan{Type: MediaMovie, Title: "Léon", Year: 1994}) + if m == nil || m.ProviderID != "1" { + t.Errorf("should match by original title, got %+v", m) + } +} + +func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) { + p := &fakeProvider{ + candidates: []metadata.Candidate{{ID: "60622", Title: "Fargo", Year: 2014}}, + counts: map[int]int{1: 10, 2: 10}, + } + r := recognizerWith(p) + m := r.matchMetadata(context.Background(), + Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}) + if m == nil || m.SeasonEpisodeCounts[1] != 10 { + t.Errorf("counts not fetched: %+v", m) + } +} + +func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) { + p := &fakeProvider{searchErr: errors.New("upstream down")} + r := recognizerWith(p) + if m := r.matchMetadata(context.Background(), + Plan{Type: MediaMovie, Title: "X", Year: 2000}); m != nil { + t.Errorf("provider error must yield no match, got %+v", m) + } +} + +func TestMatchMetadata_Disabled(t *testing.T) { + r := recognizerWith(nil) + if m := r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "X"}); m != nil { + t.Errorf("no providers → no match, got %+v", m) + } +} + +func TestNormalize(t *testing.T) { + cases := map[string]string{ + "The Matrix": "the matrix", + "Léon: The Pro!": "léon the pro", + " A B ": "a b", + "Привет, Мир": "привет мир", + } + for in, want := range cases { + if got := normalize(in); got != want { + t.Errorf("normalize(%q) = %q, want %q", in, got, want) + } + } +} + +// Сквозной авто: LLM-план + матч в базе + чистая валидация → Decision.Auto. +func TestRecognize_AutoWithMatch(t *testing.T) { + in := Input{Name: "The.Matrix.1999", Files: []File{{Path: "m/film.mkv", Size: 1}}} + resp := `{"type":"movie","title":"The Matrix","year":1999,"confidence":0.95, + "provider_hint":"The Matrix","files":[{"src":"m/film.mkv","role":"main"}]}` + llmFake := &fakeLLM{responses: []string{resp}} + p := &fakeProvider{candidates: []metadata.Candidate{ + {Provider: "tmdb", ID: "603", Title: "The Matrix", Year: 1999}, + }} + r := New(llmFake, []metadata.Provider{p}, Config{}, testLogger()) + + res, err := r.Recognize(context.Background(), in) + if err != nil { + t.Fatalf("Recognize: %v", err) + } + if !res.Decision.Auto { + t.Errorf("expected auto, reasons: %v", res.Decision.Reasons) + } + if res.Match == nil || res.Match.ProviderID != "603" { + t.Errorf("match = %+v", res.Match) + } +} diff --git a/internal/recognize/recognize.go b/internal/recognize/recognize.go index 9deb2be..8e4dbca 100644 --- a/internal/recognize/recognize.go +++ b/internal/recognize/recognize.go @@ -4,14 +4,16 @@ // Конвейер (см. docs/specs/recognition.md): // 1. пред-парс имени релиза (go-ptn) — черновые название/год/сезон/серия; // 2. вызов LLM со структурированным выводом → план в нашей схеме; -// 3. валидация плана в Go (схема + структура + согласованность сигналов); -// 4. решение «авто или review». +// 3. сверка с базами метаданных (TMDB/TVDB, опц.) — единичный сильный матч +// по названию+году даёт официальный id и каноническое имя; +// 4. решение «авто или review»: авто только при подтверждённом матче, +// чистой структурной валидации (для сериала — число серий бьётся с +// базой), согласованности с пред-парсом и уверенности не ниже порога. // -// Ф2 не сверяется с метабазами (TMDB/TVDB — Ф4) и ничего не пишет на диск: -// без подтверждённого матча в базе авто-раскладка не делается, поэтому в -// этой фазе решение всегда «review». Выход LLM недоверенный — план -// принимается только если каждый files[].src совпадает с реальным файлом -// торрента; итоговая безопасность пути держится на раскладке (Ф3). +// Без включённых баз (или без матча) авто-раскладка не делается — задача +// уходит в review. Выход LLM недоверенный: план принимается только если +// каждый files[].src совпадает с реальным файлом торрента; итоговая +// безопасность пути держится на раскладке (layout). package recognize import ( @@ -20,6 +22,7 @@ import ( "log/slog" "git.vakhrushev.me/av/jellybit/internal/llm" + "git.vakhrushev.me/av/jellybit/internal/metadata" ) // MediaType — вид контента. @@ -101,11 +104,21 @@ type Decision struct { Reasons []string // причины ухода в review / предупреждения валидации } +// Match — подтверждение распознавания базой метаданных. +type Match struct { + Provider string // "tmdb" | "tvdb" + ProviderID string // официальный id + Title string // каноническое название + Year int // каноничный год + SeasonEpisodeCounts map[int]int // число серий по сезонам (для сериала) +} + // Result — итог распознавания. type Result struct { Plan Plan PreParse PreParse Decision Decision + Match *Match // подтверждённый матч в базе (nil — нет) Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи разбора) Raw string // сырой ответ LLM последней попытки (для recognition.raw_llm) } @@ -117,27 +130,32 @@ type LLM interface { // Config — параметры распознавания. type Config struct { - MaxRetries int // переразбор ответа со схемой-в-промпте ([llm].max_retries) - MaxTokens int // лимит ответа модели (0 — дефолт) - MaxFiles int // усечение списка файлов в промпте (0 — дефолт) + MaxRetries int // переразбор ответа со схемой-в-промпте ([llm].max_retries) + MaxTokens int // лимит ответа модели (0 — дефолт) + MaxFiles int // усечение списка файлов в промпте (0 — дефолт) + AutoThreshold float64 // порог уверенности для авто (0 — дефолт 0.85) } const ( - defaultMaxTokens = 4000 - defaultMaxFiles = 100 + defaultMaxTokens = 4000 + defaultMaxFiles = 100 + defaultAutoThreshold = 0.85 ) // Recognizer — реализация распознавания. type Recognizer struct { llm LLM + providers []metadata.Provider maxRetry int maxTokens int maxFiles int + threshold float64 log *slog.Logger } -// New собирает распознаватель. -func New(provider LLM, cfg Config, log *slog.Logger) *Recognizer { +// New собирает распознаватель. providers — включённые базы метаданных +// (пусто → сверки нет, авто-раскладка не делается). +func New(provider LLM, providers []metadata.Provider, cfg Config, log *slog.Logger) *Recognizer { maxTokens := cfg.MaxTokens if maxTokens <= 0 { maxTokens = defaultMaxTokens @@ -150,11 +168,17 @@ func New(provider LLM, cfg Config, log *slog.Logger) *Recognizer { if retries < 0 { retries = 0 } + threshold := cfg.AutoThreshold + if threshold <= 0 { + threshold = defaultAutoThreshold + } return &Recognizer{ llm: provider, + providers: providers, maxRetry: retries, maxTokens: maxTokens, maxFiles: maxFiles, + threshold: threshold, log: log, } } @@ -209,15 +233,26 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) { }, nil } - dec := decide(plan, pre) + // Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год + // в плане заменяем на каноничные. + match := r.matchMetadata(ctx, plan) + if match != nil { + plan.Title = match.Title + if match.Year != 0 { + plan.Year = match.Year + } + } + + dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold) r.log.Info("recognize: done", "type", plan.Type, "title", plan.Title, "year", plan.Year, "files", len(plan.Files), "attempts", attempts, - "auto", dec.Auto, "reasons", len(dec.Reasons)) + "matched", match != nil, "auto", dec.Auto, "reasons", len(dec.Reasons)) return Result{ Plan: plan, PreParse: pre, Decision: dec, + Match: match, Attempts: attempts, Raw: raw, }, nil diff --git a/internal/recognize/recognize_test.go b/internal/recognize/recognize_test.go index c44bf61..4376099 100644 --- a/internal/recognize/recognize_test.go +++ b/internal/recognize/recognize_test.go @@ -56,7 +56,7 @@ func TestRecognize_Movie(t *testing.T) { {"src":"The.Matrix.1999/sample.mkv","role":"sample","season":null,"episode":null} ]}` f := &fakeLLM{responses: []string{resp}} - r := New(f, Config{MaxRetries: 2}, testLogger()) + r := New(f, nil, Config{MaxRetries: 2}, testLogger()) res, err := r.Recognize(context.Background(), in) if err != nil { @@ -74,9 +74,10 @@ func TestRecognize_Movie(t *testing.T) { if len(res.Decision.Reasons) == 0 { t.Error("expected at least the no-DB-match reason") } - // Чистая структура: единственная причина — отсутствие матча в базе. - if len(res.Decision.Reasons) != 1 { - t.Errorf("unexpected extra warnings: %v", res.Decision.Reasons) + // Чистая структура + уверенность 0.9 ≥ порога: единственная причина — + // отсутствие матча в базе. + if len(res.Decision.Reasons) != 1 || !hasReason(res.Decision.Reasons, "метабазы отключены") { + t.Errorf("unexpected reasons: %v", res.Decision.Reasons) } } @@ -96,7 +97,7 @@ func TestRecognize_Series(t *testing.T) { {"src":"Avatar/03.mkv","role":"episode","season":2,"episode":3} ]}` f := &fakeLLM{responses: []string{resp}} - r := New(f, Config{}, testLogger()) + r := New(f, nil, Config{}, testLogger()) res, err := r.Recognize(context.Background(), in) if err != nil { @@ -105,9 +106,22 @@ func TestRecognize_Series(t *testing.T) { if res.Plan.Type != MediaSeries || len(res.Plan.Files) != 3 { t.Errorf("plan = %+v", res.Plan) } - if len(res.Decision.Reasons) != 1 { - t.Errorf("clean series should warn only about DB match, got: %v", res.Decision.Reasons) + // Метабазы выключены → авто нет; причина про базу обязательна. + if res.Decision.Auto { + t.Error("auto must be false without metadata providers") } + if !hasReason(res.Decision.Reasons, "метабазы отключены") { + t.Errorf("expected metadata-off reason, got: %v", res.Decision.Reasons) + } +} + +func hasReason(reasons []string, substr string) bool { + for _, r := range reasons { + if strings.Contains(r, substr) { + return true + } + } + return false } func TestRecognize_RetriesOnBadSrcThenSucceeds(t *testing.T) { @@ -120,7 +134,7 @@ func TestRecognize_RetriesOnBadSrcThenSucceeds(t *testing.T) { good := `{"type":"movie","title":"Some Movie","year":2020,"files":[ {"src":"movie/film.mkv","role":"main"}]}` f := &fakeLLM{responses: []string{bad, good}} - r := New(f, Config{MaxRetries: 2}, testLogger()) + r := New(f, nil, Config{MaxRetries: 2}, testLogger()) res, err := r.Recognize(context.Background(), in) if err != nil { @@ -143,7 +157,7 @@ func TestRecognize_ExhaustedRetriesGoesToReview(t *testing.T) { in := Input{Name: "x", Files: []File{{Path: "a.mkv", Size: 1}}} bad := `not a json at all` f := &fakeLLM{responses: []string{bad}} - r := New(f, Config{MaxRetries: 2}, testLogger()) + r := New(f, nil, Config{MaxRetries: 2}, testLogger()) res, err := r.Recognize(context.Background(), in) if err != nil { @@ -167,7 +181,7 @@ func TestRecognize_TransportErrorPropagates(t *testing.T) { in := Input{Name: "x", Files: []File{{Path: "a.mkv", Size: 1}}} wantErr := errors.New("connection refused") f := &fakeLLM{errs: []error{wantErr}} - r := New(f, Config{MaxRetries: 2}, testLogger()) + r := New(f, nil, Config{MaxRetries: 2}, testLogger()) _, err := r.Recognize(context.Background(), in) if err == nil || !errors.Is(err, wantErr) { @@ -188,7 +202,7 @@ func TestRecognize_PromptCarriesSignals(t *testing.T) { resp := `{"type":"series","title":"Some Show","files":[ {"src":"ep1.mkv","role":"episode","season":1,"episode":1}]}` f := &fakeLLM{responses: []string{resp}} - r := New(f, Config{}, testLogger()) + r := New(f, nil, Config{}, testLogger()) if _, err := r.Recognize(context.Background(), in); err != nil { t.Fatalf("Recognize: %v", err) } @@ -219,7 +233,7 @@ func TestRecognize_FileListTruncated(t *testing.T) { resp := `{"type":"series","title":"Big","files":[{"src":"` + pathOf(0) + `","role":"episode","season":1,"episode":1}]}` f := &fakeLLM{responses: []string{resp}} - r := New(f, Config{MaxFiles: 100}, testLogger()) + r := New(f, nil, Config{MaxFiles: 100}, testLogger()) if _, err := r.Recognize(context.Background(), in); err != nil { t.Fatalf("Recognize: %v", err) } diff --git a/internal/recognize/validate.go b/internal/recognize/validate.go index 810e0a6..d7b9938 100644 --- a/internal/recognize/validate.go +++ b/internal/recognize/validate.go @@ -76,15 +76,72 @@ func validateSchema(p *Plan, in Input) error { return nil } -// decide считает решение модели уверенности. В Ф2 метабазы выключены, а без -// подтверждённого матча в базе авто-раскладка не делается (recognition.md), -// поэтому Auto всегда false; здесь же копим структурные предупреждения и -// расхождения с пред-парсом — они объясняют ревью человеку. -func decide(p Plan, pre PreParse) Decision { - reasons := []string{"матч в базе не подтверждён (метабазы отключены в Ф2) → review"} +// decide считает решение модели уверенности (см. recognition.md). Авто — +// только если выполнено всё: подтверждённый единичный матч в базе; чистая +// структурная валидация (для сериала — число серий бьётся с базой); +// согласованность с пред-парсом; самооценка LLM не ниже порога. Любая +// невыполненная — причина ухода в review. +func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision { + var reasons []string + + switch { + case !metadataEnabled: + reasons = append(reasons, "метабазы отключены → авто-раскладка недоступна") + case match == nil: + reasons = append(reasons, "не найдено в базе или несколько кандидатов") + } + reasons = append(reasons, structuralWarnings(p)...) + + if match != nil && p.Type == MediaSeries { + reasons = append(reasons, episodeCountWarnings(p, match.SeasonEpisodeCounts)...) + } + reasons = append(reasons, consistencyWarnings(p, pre)...) - return Decision{Auto: false, Reasons: reasons} + + if p.Confidence < threshold { + reasons = append(reasons, + fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold)) + } + + return Decision{Auto: len(reasons) == 0, Reasons: reasons} +} + +// episodeCountWarnings сверяет число распознанных серий по сезонам с базой. +// Нет данных по сезону → блокируем авто (полноту пака не подтвердить). +func episodeCountWarnings(p Plan, counts map[int]int) []string { + recognized := map[int]int{} + for _, f := range p.Files { + if f.Role == RoleEpisode && f.Episode != nil { + season := 0 + if f.Season != nil { + season = *f.Season + } + recognized[season]++ + } + } + var w []string + for _, season := range sortedKeys(toSlices(recognized)) { + rc := recognized[season] + dbc, ok := counts[season] + switch { + case !ok || dbc == 0: + w = append(w, fmt.Sprintf("сезон %d: в базе нет данных о числе серий", season)) + case rc != dbc: + w = append(w, fmt.Sprintf("сезон %d: распознано серий %d, в базе %d", season, rc, dbc)) + } + } + return w +} + +// toSlices превращает map[int]int в map[int][]int для sortedKeys (нужны +// только ключи). +func toSlices(m map[int]int) map[int][]int { + out := make(map[int][]int, len(m)) + for k := range m { + out[k] = nil + } + return out } // structuralWarnings — нарушения структуры плана (мягкие, не блокируют разбор). diff --git a/internal/recognize/validate_test.go b/internal/recognize/validate_test.go index 83e53f3..6093546 100644 --- a/internal/recognize/validate_test.go +++ b/internal/recognize/validate_test.go @@ -155,17 +155,71 @@ func TestConsistencyWarnings(t *testing.T) { } } -func TestDecide_AlwaysReview(t *testing.T) { - p := Plan{Type: MediaMovie, Title: "X", Files: []PlanFile{{Role: RoleMain}}} - d := decide(p, PreParse{}) +func TestDecide_MetadataDisabled(t *testing.T) { + p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}} + d := decide(p, PreParse{}, nil, false, 0.85) if d.Auto { - t.Error("Ф2 decision must never be auto") + t.Error("без метабаз авто недопустимо") } if len(d.Reasons) == 0 || !strings.Contains(d.Reasons[0], "метабазы отключены") { t.Errorf("first reason should be DB match, got %v", d.Reasons) } } +func TestDecide_NoMatch(t *testing.T) { + p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}} + d := decide(p, PreParse{}, nil, true, 0.85) + if d.Auto || !strings.Contains(d.Reasons[0], "не найдено в базе") { + t.Errorf("reasons = %v", d.Reasons) + } +} + +func TestDecide_AutoMovie(t *testing.T) { + p := Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999, Confidence: 0.95, + Files: []PlanFile{{Role: RoleMain}, {Role: RoleSample}}} + match := &Match{Provider: "tmdb", ProviderID: "603", Title: "The Matrix", Year: 1999} + d := decide(p, PreParse{Year: 1999}, match, true, 0.85) + if !d.Auto { + t.Errorf("clean movie with match must be auto, reasons: %v", d.Reasons) + } +} + +func TestDecide_LowConfidenceBlocksAuto(t *testing.T) { + p := Plan{Type: MediaMovie, Title: "X", Year: 2000, Confidence: 0.5, + Files: []PlanFile{{Role: RoleMain}}} + match := &Match{Provider: "tmdb", ProviderID: "1", Title: "X", Year: 2000} + d := decide(p, PreParse{}, match, true, 0.85) + if d.Auto || !hasReason(d.Reasons, "уверенность") { + t.Errorf("low confidence must block auto, reasons: %v", d.Reasons) + } +} + +func TestDecide_AutoSeriesEpisodeCount(t *testing.T) { + s := 2 + mk := func(e int) PlanFile { ep := e; return PlanFile{Role: RoleEpisode, Season: &s, Episode: &ep} } + p := Plan{Type: MediaSeries, Title: "Fargo", Year: 2014, Confidence: 0.9, + Files: []PlanFile{mk(1), mk(2), mk(3)}} + match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Fargo", Year: 2014, + SeasonEpisodeCounts: map[int]int{2: 3}} + if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); !d.Auto { + t.Errorf("full season must be auto, reasons: %v", d.Reasons) + } + + // Неполный пак (в базе 10) — авто блокируется. + match.SeasonEpisodeCounts = map[int]int{2: 10} + if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); d.Auto || + !hasReason(d.Reasons, "распознано серий 3, в базе 10") { + t.Errorf("partial pack must block auto, reasons: %v", d.Reasons) + } + + // Нет данных о числе серий — авто блокируется. + match.SeasonEpisodeCounts = nil + if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); d.Auto || + !hasReason(d.Reasons, "нет данных о числе серий") { + t.Errorf("missing counts must block auto, reasons: %v", d.Reasons) + } +} + func TestPreParse(t *testing.T) { pre := preParse("The.Matrix.1999.1080p.BluRay.x264") if pre.Year != 1999 { diff --git a/internal/worker/review.go b/internal/worker/review.go index c41f6e5..ba1ec7b 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -112,18 +112,25 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize. // finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3 // метабазы выключены → авто-раскладки не делаем, всегда уходим в review. -func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.Result, _ string) { +func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.Result, savePath string) { planJSON, err := json.Marshal(res.Plan) if err != nil { w.log.Error("recognize: marshal plan", "download_id", id, "err", err) planJSON = []byte("{}") } + provider, providerID, tag := "none", "", "" + if res.Match != nil { + provider, providerID = res.Match.Provider, res.Match.ProviderID + tag = providerTag(res.Match.Provider, res.Match.ProviderID) + } + rec := &store.Recognition{ DownloadID: id, MediaType: store.NullString(string(res.Plan.Type)), Title: store.NullString(res.Plan.Title), - Provider: store.NullString("none"), + Provider: store.NullString(provider), + ProviderID: store.NullString(providerID), Plan: store.NullString(string(planJSON)), RawLLM: store.NullString(res.Raw), } @@ -155,9 +162,31 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize. w.log.Error("recognize: persist", "download_id", id, "err", err) return } + + // Авто-раскладка при подтверждённом матче и чистой валидации (Ф4); + // иначе — review. Раскладчик может быть не сконфигурирован. + if res.Decision.Auto && w.layouter != nil { + plan := applyOverrides(res.Plan, w.overridesOrNil(ctx, id)) + w.transition(ctx, *d, store.StateLinking, "", "") + if err := w.linkPlan(ctx, d, plan, tag, savePath); err != nil { + w.log.Warn("recognize: auto-apply failed, left for review", + "download_id", id, "err", err) + } + return + } w.transition(ctx, *d, store.StateReview, "", "") } +// overridesOrNil читает правки, проглатывая ошибку (для авто-пути). +func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string { + o, err := w.store.ListOverrides(ctx, id) + if err != nil { + w.log.Warn("recognize: list overrides", "download_id", id, "err", err) + return nil + } + return o +} + // --- Команды ревью --- // Apply создаёт хардлинки по текущему плану (с применёнными правками) и @@ -177,7 +206,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error { return fmt.Errorf("apply: задача %d в состоянии %s (ожидалось review/deferred)", id, d.State) } - plan, err := w.effectivePlan(ctx, id) + plan, tag, err := w.effectivePlan(ctx, id) if err != nil { return fmt.Errorf("apply: %w", err) } @@ -186,9 +215,21 @@ func (w *Worker) Apply(ctx context.Context, id int64) error { return fmt.Errorf("apply: торрент не найден: %v", err) } - links, err := w.layouter.BuildLinks(toLayoutPlan(plan, t.SavePath)) + w.transition(ctx, *d, store.StateLinking, "", "") + if err := w.linkPlan(ctx, d, plan, tag, t.SavePath); err != nil { + return fmt.Errorf("apply: %w", err) + } + return nil +} + +// linkPlan строит и создаёт хардлинки по плану, фиксирует батч ссылок и +// двигает задачу: done при успехе, review при коллизии/невалидном плане, +// failed при иной ошибке ФС. Идемпотентен (повтор доводит начатое). Под mu. +func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize.Plan, providerTag, savePath string) error { + links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag)) if err != nil { - return fmt.Errorf("apply: построение ссылок: %w", err) + w.transition(ctx, *d, store.StateReview, "build", err.Error()) + return fmt.Errorf("построение ссылок: %w", err) } batch := w.newID() @@ -198,7 +239,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error { fl := make([]store.FileLink, 0, len(results)) for _, r := range results { fl = append(fl, store.FileLink{ - DownloadID: id, + DownloadID: d.ID, ApplyBatchID: batch, SrcPath: r.Link.Src, DstPath: r.Link.Dst, @@ -208,21 +249,21 @@ func (w *Worker) Apply(ctx context.Context, id int64) error { } if len(fl) > 0 { if err := w.store.CreateFileLinks(ctx, fl); err != nil { - return fmt.Errorf("apply: запись ссылок: %w", err) + return fmt.Errorf("запись ссылок: %w", err) } } if applyErr != nil { if errors.Is(applyErr, layout.ErrCollision) { w.transition(ctx, *d, store.StateReview, "collision", applyErr.Error()) - return fmt.Errorf("apply: %w", applyErr) + return applyErr } w.transition(ctx, *d, store.StateFailed, "apply", applyErr.Error()) - return fmt.Errorf("apply: %w", applyErr) + return applyErr } w.transition(ctx, *d, store.StateDone, "", "") - w.log.Info("apply: linked", "download_id", id, "batch", batch, "links", len(fl)) + w.log.Info("apply: linked", "download_id", d.ID, "batch", batch, "links", len(fl)) return nil } @@ -409,10 +450,11 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error) if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err == nil { plan = applyOverrides(plan, overrides) rd.Plan = plan - // Превью строим по относительным путям; ошибку игнорируем — - // просто покажем причины без превью. + // Превью строим по относительным путям с provider-тегом; ошибку + // игнорируем — просто покажем причины без превью. if w.layouter != nil { - if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "")); lerr == nil { + tag := providerTag(rec.Provider.String, rec.ProviderID.String) + if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil { rd.Preview = links } } @@ -421,24 +463,26 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error) return rd, nil } -// effectivePlan загружает текущий план и применяет правки (под mu). -func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, error) { +// effectivePlan загружает текущий план, применяет правки и возвращает +// provider-тег для имени папки (под mu). +func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, string, error) { rec, err := w.store.GetCurrentRecognition(ctx, id) if err != nil { - return recognize.Plan{}, err + return recognize.Plan{}, "", err } if rec == nil || !rec.Plan.Valid { - return recognize.Plan{}, fmt.Errorf("нет плана распознавания") + return recognize.Plan{}, "", fmt.Errorf("нет плана распознавания") } var plan recognize.Plan if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil { - return recognize.Plan{}, fmt.Errorf("разбор плана: %w", err) + return recognize.Plan{}, "", fmt.Errorf("разбор плана: %w", err) } overrides, err := w.store.ListOverrides(ctx, id) if err != nil { - return recognize.Plan{}, err + return recognize.Plan{}, "", err } - return applyOverrides(plan, overrides), nil + tag := providerTag(rec.Provider.String, rec.ProviderID.String) + return applyOverrides(plan, overrides), tag, nil } // --- Хелперы преобразования --- @@ -460,14 +504,32 @@ func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize. return plan } +// providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…" +// / "tvdbid-…". Пустой id (нет матча) → пустой тег. +func providerTag(provider, id string) string { + if id == "" { + return "" + } + switch provider { + case "tmdb": + return "tmdbid-" + id + case "tvdb": + return "tvdbid-" + id + default: + return "" + } +} + // toLayoutPlan переводит план распознавания в план раскладки. srcPrefix // (savePath) приклеивается к относительным путям файлов; пустой — оставляет -// относительные (для превью). Роли вне main/episode/subtitle отбрасываются. -func toLayoutPlan(plan recognize.Plan, srcPrefix string) layout.Plan { +// относительные (для превью). providerTag добавляется к имени папки. Роли +// вне main/episode/subtitle отбрасываются. +func toLayoutPlan(plan recognize.Plan, srcPrefix, providerTag string) layout.Plan { lp := layout.Plan{ - Type: layout.MediaType(plan.Type), - Title: plan.Title, - Year: plan.Year, + Type: layout.MediaType(plan.Type), + Title: plan.Title, + Year: plan.Year, + ProviderTag: providerTag, } for _, f := range plan.Files { role, ok := mapRole(f.Role) diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index e842745..59f9974 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -528,6 +528,80 @@ func TestApplyOverrides(t *testing.T) { } } +func TestRecognizeOne_AutoApplies(t *testing.T) { + root := t.TempDir() + downloads := filepath.Join(root, "downloads") + movies := filepath.Join(root, "movies") + series := filepath.Join(root, "series") + for _, d := range []string{downloads, movies, series} { + _ = os.MkdirAll(d, 0o755) + } + plan := seriesResult().Plan + plan.Confidence = 0.95 + for _, f := range plan.Files { + p := filepath.Join(downloads, f.Src) + _ = os.MkdirAll(filepath.Dir(p), 0o755) + _ = os.WriteFile(p, []byte("x"), 0o644) + } + lay, _ := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}) + + st := newMemStore() + st.put(completedDownload(1)) + qb := &fakeQbt{ + torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: downloads, Category: "jellybit"}}, + files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}, {Name: "Show/e2.mkv", Size: 1}}, + } + rec := &fakeRecognizer{result: recognize.Result{ + Plan: plan, + Decision: recognize.Decision{Auto: true}, + Match: &recognize.Match{Provider: "tmdb", ProviderID: "42", Title: "Show", Year: 2006}, + }} + w := testWorkerWith(st, qb, rec, lay) + + w.recognizeOne(context.Background(), 1) + + if st.downloads[1].State != store.StateDone { + t.Fatalf("state = %q, want done (auto)", st.downloads[1].State) + } + // Provider-тег попал в имя папки. + want := filepath.Join(series, "Show (2006) [tmdbid-42]", "Season 02", "Show (2006) S02E01.mkv") + if _, err := os.Stat(want); err != nil { + t.Errorf("expected auto-linked file %q: %v", want, err) + } + if len(st.links) != 2 { + t.Errorf("file_links = %d, want 2", len(st.links)) + } +} + +func TestApply_UsesProviderTag(t *testing.T) { + f := newApplyFixture(t, seriesResult().Plan) + f.st.recs[0].Provider = store.NullString("tmdb") + f.st.recs[0].ProviderID = store.NullString("603") + + if err := f.w.Apply(context.Background(), 1); err != nil { + t.Fatalf("Apply: %v", err) + } + want := filepath.Join(f.series, "Show (2006) [tmdbid-603]", "Season 02", "Show (2006) S02E01.mkv") + if _, err := os.Stat(want); err != nil { + t.Errorf("expected tagged path %q: %v", want, err) + } +} + +func TestProviderTag(t *testing.T) { + cases := []struct{ provider, id, want string }{ + {"tmdb", "603", "tmdbid-603"}, + {"tvdb", "123", "tvdbid-123"}, + {"none", "", ""}, + {"tmdb", "", ""}, + {"weird", "1", ""}, + } + for _, c := range cases { + if got := providerTag(c.provider, c.id); got != c.want { + t.Errorf("providerTag(%q,%q) = %q, want %q", c.provider, c.id, got, c.want) + } + } +} + func TestToLayoutPlan(t *testing.T) { s, e := 1, 3 plan := recognize.Plan{ @@ -537,7 +611,7 @@ func TestToLayoutPlan(t *testing.T) { {Src: "sample.mkv", Role: "sample"}, }, } - lp := toLayoutPlan(plan, "/d") + lp := toLayoutPlan(plan, "/d", "tmdbid-1") if len(lp.Files) != 1 { t.Fatalf("want 1 linkable file, got %d", len(lp.Files)) } @@ -547,4 +621,7 @@ func TestToLayoutPlan(t *testing.T) { if lp.Files[0].Role != layout.RoleEpisode { t.Errorf("role = %q", lp.Files[0].Role) } + if lp.ProviderTag != "tmdbid-1" { + t.Errorf("provider tag = %q", lp.ProviderTag) + } } diff --git a/web/templates/review.html b/web/templates/review.html index eac62b7..8eb8de4 100644 --- a/web/templates/review.html +++ b/web/templates/review.html @@ -57,6 +57,7 @@ Тип: {{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}} · Название: {{.Title}}{{if .OriginalTitle}} ({{.OriginalTitle}}){{end}} {{if .Year}}· Год: {{.Year}}{{end}} + {{if .Provider}}· База: {{.Provider}} {{.ProviderID}}{{end}}

Переключить тип: