Добавил поиск метаданных по каталогам
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
# Реальный конфиг (секреты) и локальная БД
|
# Реальный конфиг (секреты) и локальная БД
|
||||||
/config.toml
|
/config.toml
|
||||||
|
/.env
|
||||||
*.db
|
*.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
|||||||
+44
-2
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
"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/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
@@ -60,6 +61,15 @@ func runServe(args []string) error {
|
|||||||
SavePath: cfg.QBittorrent.SavePath,
|
SavePath: cfg.QBittorrent.SavePath,
|
||||||
}, logger)
|
}, 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 не сконфигурирован,
|
// Ф2/Ф3: распознаватель и раскладчик. Если LLM не сконфигурирован,
|
||||||
// сервис работает как в Ф1 (completed-задачи дальше не двигаются).
|
// сервис работает как в Ф1 (completed-задачи дальше не двигаются).
|
||||||
var recognizer worker.Recognizer
|
var recognizer worker.Recognizer
|
||||||
@@ -75,8 +85,11 @@ func runServe(args []string) error {
|
|||||||
if perr != nil {
|
if perr != nil {
|
||||||
return fmt.Errorf("llm provider: %w", perr)
|
return fmt.Errorf("llm provider: %w", perr)
|
||||||
}
|
}
|
||||||
recognizer = recognize.New(provider, recognize.Config{MaxRetries: cfg.LLM.MaxRetries}, logger)
|
recognizer = recognize.New(provider, providers, recognize.Config{
|
||||||
logger.Info("recognizer ready", "model", cfg.LLM.Model)
|
MaxRetries: cfg.LLM.MaxRetries,
|
||||||
|
AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
|
||||||
|
}, logger)
|
||||||
|
logger.Info("recognizer ready", "model", cfg.LLM.Model, "providers", len(providers))
|
||||||
} else {
|
} else {
|
||||||
logger.Warn("llm not configured, recognition disabled")
|
logger.Warn("llm not configured, recognition disabled")
|
||||||
}
|
}
|
||||||
@@ -142,3 +155,32 @@ func runServe(args []string) error {
|
|||||||
logger.Info("stopped")
|
logger.Info("stopped")
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ type reviewView struct {
|
|||||||
Title string
|
Title string
|
||||||
OriginalTitle string
|
OriginalTitle string
|
||||||
Year int
|
Year int
|
||||||
|
Provider string
|
||||||
|
ProviderID string
|
||||||
Confidence string
|
Confidence string
|
||||||
Reasons []string
|
Reasons []string
|
||||||
Hints []string
|
Hints []string
|
||||||
@@ -85,6 +87,10 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
view.OriginalTitle = rd.Plan.OriginalTitle
|
view.OriginalTitle = rd.Plan.OriginalTitle
|
||||||
view.Year = rd.Plan.Year
|
view.Year = rd.Plan.Year
|
||||||
view.Reasons = rec.ReasonList()
|
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 {
|
if rec.Confidence.Valid {
|
||||||
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
// Package metadata — интерфейс баз метаданных и клиенты TMDB/TVDB (опц.).
|
|
||||||
//
|
|
||||||
// Заглушка: реализация в фазе Ф4 (см. docs/specs/architecture.md).
|
|
||||||
package metadata
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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("ожидались данные о числе серий по сезонам")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ func TestIntegration_RecognizeSeries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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)/"
|
const dir = "Аватар Легенда об Аанге.Книга 2.Земля(Avatar The Last Airbender The book 2.Earth)/"
|
||||||
in := recognize.Input{
|
in := recognize.Input{
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,16 @@
|
|||||||
// Конвейер (см. docs/specs/recognition.md):
|
// Конвейер (см. docs/specs/recognition.md):
|
||||||
// 1. пред-парс имени релиза (go-ptn) — черновые название/год/сезон/серия;
|
// 1. пред-парс имени релиза (go-ptn) — черновые название/год/сезон/серия;
|
||||||
// 2. вызов LLM со структурированным выводом → план в нашей схеме;
|
// 2. вызов LLM со структурированным выводом → план в нашей схеме;
|
||||||
// 3. валидация плана в Go (схема + структура + согласованность сигналов);
|
// 3. сверка с базами метаданных (TMDB/TVDB, опц.) — единичный сильный матч
|
||||||
// 4. решение «авто или review».
|
// по названию+году даёт официальный id и каноническое имя;
|
||||||
|
// 4. решение «авто или review»: авто только при подтверждённом матче,
|
||||||
|
// чистой структурной валидации (для сериала — число серий бьётся с
|
||||||
|
// базой), согласованности с пред-парсом и уверенности не ниже порога.
|
||||||
//
|
//
|
||||||
// Ф2 не сверяется с метабазами (TMDB/TVDB — Ф4) и ничего не пишет на диск:
|
// Без включённых баз (или без матча) авто-раскладка не делается — задача
|
||||||
// без подтверждённого матча в базе авто-раскладка не делается, поэтому в
|
// уходит в review. Выход LLM недоверенный: план принимается только если
|
||||||
// этой фазе решение всегда «review». Выход LLM недоверенный — план
|
// каждый files[].src совпадает с реальным файлом торрента; итоговая
|
||||||
// принимается только если каждый files[].src совпадает с реальным файлом
|
// безопасность пути держится на раскладке (layout).
|
||||||
// торрента; итоговая безопасность пути держится на раскладке (Ф3).
|
|
||||||
package recognize
|
package recognize
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -20,6 +22,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MediaType — вид контента.
|
// MediaType — вид контента.
|
||||||
@@ -101,11 +104,21 @@ type Decision struct {
|
|||||||
Reasons []string // причины ухода в review / предупреждения валидации
|
Reasons []string // причины ухода в review / предупреждения валидации
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match — подтверждение распознавания базой метаданных.
|
||||||
|
type Match struct {
|
||||||
|
Provider string // "tmdb" | "tvdb"
|
||||||
|
ProviderID string // официальный id
|
||||||
|
Title string // каноническое название
|
||||||
|
Year int // каноничный год
|
||||||
|
SeasonEpisodeCounts map[int]int // число серий по сезонам (для сериала)
|
||||||
|
}
|
||||||
|
|
||||||
// Result — итог распознавания.
|
// Result — итог распознавания.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Plan Plan
|
Plan Plan
|
||||||
PreParse PreParse
|
PreParse PreParse
|
||||||
Decision Decision
|
Decision Decision
|
||||||
|
Match *Match // подтверждённый матч в базе (nil — нет)
|
||||||
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи разбора)
|
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи разбора)
|
||||||
Raw string // сырой ответ LLM последней попытки (для recognition.raw_llm)
|
Raw string // сырой ответ LLM последней попытки (для recognition.raw_llm)
|
||||||
}
|
}
|
||||||
@@ -120,24 +133,29 @@ type Config struct {
|
|||||||
MaxRetries int // переразбор ответа со схемой-в-промпте ([llm].max_retries)
|
MaxRetries int // переразбор ответа со схемой-в-промпте ([llm].max_retries)
|
||||||
MaxTokens int // лимит ответа модели (0 — дефолт)
|
MaxTokens int // лимит ответа модели (0 — дефолт)
|
||||||
MaxFiles int // усечение списка файлов в промпте (0 — дефолт)
|
MaxFiles int // усечение списка файлов в промпте (0 — дефолт)
|
||||||
|
AutoThreshold float64 // порог уверенности для авто (0 — дефолт 0.85)
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultMaxTokens = 4000
|
defaultMaxTokens = 4000
|
||||||
defaultMaxFiles = 100
|
defaultMaxFiles = 100
|
||||||
|
defaultAutoThreshold = 0.85
|
||||||
)
|
)
|
||||||
|
|
||||||
// Recognizer — реализация распознавания.
|
// Recognizer — реализация распознавания.
|
||||||
type Recognizer struct {
|
type Recognizer struct {
|
||||||
llm LLM
|
llm LLM
|
||||||
|
providers []metadata.Provider
|
||||||
maxRetry int
|
maxRetry int
|
||||||
maxTokens int
|
maxTokens int
|
||||||
maxFiles int
|
maxFiles int
|
||||||
|
threshold float64
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New собирает распознаватель.
|
// New собирает распознаватель. providers — включённые базы метаданных
|
||||||
func New(provider LLM, cfg Config, log *slog.Logger) *Recognizer {
|
// (пусто → сверки нет, авто-раскладка не делается).
|
||||||
|
func New(provider LLM, providers []metadata.Provider, cfg Config, log *slog.Logger) *Recognizer {
|
||||||
maxTokens := cfg.MaxTokens
|
maxTokens := cfg.MaxTokens
|
||||||
if maxTokens <= 0 {
|
if maxTokens <= 0 {
|
||||||
maxTokens = defaultMaxTokens
|
maxTokens = defaultMaxTokens
|
||||||
@@ -150,11 +168,17 @@ func New(provider LLM, cfg Config, log *slog.Logger) *Recognizer {
|
|||||||
if retries < 0 {
|
if retries < 0 {
|
||||||
retries = 0
|
retries = 0
|
||||||
}
|
}
|
||||||
|
threshold := cfg.AutoThreshold
|
||||||
|
if threshold <= 0 {
|
||||||
|
threshold = defaultAutoThreshold
|
||||||
|
}
|
||||||
return &Recognizer{
|
return &Recognizer{
|
||||||
llm: provider,
|
llm: provider,
|
||||||
|
providers: providers,
|
||||||
maxRetry: retries,
|
maxRetry: retries,
|
||||||
maxTokens: maxTokens,
|
maxTokens: maxTokens,
|
||||||
maxFiles: maxFiles,
|
maxFiles: maxFiles,
|
||||||
|
threshold: threshold,
|
||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,15 +233,26 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
|||||||
}, nil
|
}, 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",
|
r.log.Info("recognize: done",
|
||||||
"type", plan.Type, "title", plan.Title, "year", plan.Year,
|
"type", plan.Type, "title", plan.Title, "year", plan.Year,
|
||||||
"files", len(plan.Files), "attempts", attempts,
|
"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{
|
return Result{
|
||||||
Plan: plan,
|
Plan: plan,
|
||||||
PreParse: pre,
|
PreParse: pre,
|
||||||
Decision: dec,
|
Decision: dec,
|
||||||
|
Match: match,
|
||||||
Attempts: attempts,
|
Attempts: attempts,
|
||||||
Raw: raw,
|
Raw: raw,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func TestRecognize_Movie(t *testing.T) {
|
|||||||
{"src":"The.Matrix.1999/sample.mkv","role":"sample","season":null,"episode":null}
|
{"src":"The.Matrix.1999/sample.mkv","role":"sample","season":null,"episode":null}
|
||||||
]}`
|
]}`
|
||||||
f := &fakeLLM{responses: []string{resp}}
|
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)
|
res, err := r.Recognize(context.Background(), in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -74,9 +74,10 @@ func TestRecognize_Movie(t *testing.T) {
|
|||||||
if len(res.Decision.Reasons) == 0 {
|
if len(res.Decision.Reasons) == 0 {
|
||||||
t.Error("expected at least the no-DB-match reason")
|
t.Error("expected at least the no-DB-match reason")
|
||||||
}
|
}
|
||||||
// Чистая структура: единственная причина — отсутствие матча в базе.
|
// Чистая структура + уверенность 0.9 ≥ порога: единственная причина —
|
||||||
if len(res.Decision.Reasons) != 1 {
|
// отсутствие матча в базе.
|
||||||
t.Errorf("unexpected extra warnings: %v", res.Decision.Reasons)
|
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}
|
{"src":"Avatar/03.mkv","role":"episode","season":2,"episode":3}
|
||||||
]}`
|
]}`
|
||||||
f := &fakeLLM{responses: []string{resp}}
|
f := &fakeLLM{responses: []string{resp}}
|
||||||
r := New(f, Config{}, testLogger())
|
r := New(f, nil, Config{}, testLogger())
|
||||||
|
|
||||||
res, err := r.Recognize(context.Background(), in)
|
res, err := r.Recognize(context.Background(), in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,9 +106,22 @@ func TestRecognize_Series(t *testing.T) {
|
|||||||
if res.Plan.Type != MediaSeries || len(res.Plan.Files) != 3 {
|
if res.Plan.Type != MediaSeries || len(res.Plan.Files) != 3 {
|
||||||
t.Errorf("plan = %+v", res.Plan)
|
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) {
|
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":[
|
good := `{"type":"movie","title":"Some Movie","year":2020,"files":[
|
||||||
{"src":"movie/film.mkv","role":"main"}]}`
|
{"src":"movie/film.mkv","role":"main"}]}`
|
||||||
f := &fakeLLM{responses: []string{bad, good}}
|
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)
|
res, err := r.Recognize(context.Background(), in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,7 +157,7 @@ func TestRecognize_ExhaustedRetriesGoesToReview(t *testing.T) {
|
|||||||
in := Input{Name: "x", Files: []File{{Path: "a.mkv", Size: 1}}}
|
in := Input{Name: "x", Files: []File{{Path: "a.mkv", Size: 1}}}
|
||||||
bad := `not a json at all`
|
bad := `not a json at all`
|
||||||
f := &fakeLLM{responses: []string{bad}}
|
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)
|
res, err := r.Recognize(context.Background(), in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -167,7 +181,7 @@ func TestRecognize_TransportErrorPropagates(t *testing.T) {
|
|||||||
in := Input{Name: "x", Files: []File{{Path: "a.mkv", Size: 1}}}
|
in := Input{Name: "x", Files: []File{{Path: "a.mkv", Size: 1}}}
|
||||||
wantErr := errors.New("connection refused")
|
wantErr := errors.New("connection refused")
|
||||||
f := &fakeLLM{errs: []error{wantErr}}
|
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)
|
_, err := r.Recognize(context.Background(), in)
|
||||||
if err == nil || !errors.Is(err, wantErr) {
|
if err == nil || !errors.Is(err, wantErr) {
|
||||||
@@ -188,7 +202,7 @@ func TestRecognize_PromptCarriesSignals(t *testing.T) {
|
|||||||
resp := `{"type":"series","title":"Some Show","files":[
|
resp := `{"type":"series","title":"Some Show","files":[
|
||||||
{"src":"ep1.mkv","role":"episode","season":1,"episode":1}]}`
|
{"src":"ep1.mkv","role":"episode","season":1,"episode":1}]}`
|
||||||
f := &fakeLLM{responses: []string{resp}}
|
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 {
|
if _, err := r.Recognize(context.Background(), in); err != nil {
|
||||||
t.Fatalf("Recognize: %v", err)
|
t.Fatalf("Recognize: %v", err)
|
||||||
}
|
}
|
||||||
@@ -219,7 +233,7 @@ func TestRecognize_FileListTruncated(t *testing.T) {
|
|||||||
resp := `{"type":"series","title":"Big","files":[{"src":"` + pathOf(0) +
|
resp := `{"type":"series","title":"Big","files":[{"src":"` + pathOf(0) +
|
||||||
`","role":"episode","season":1,"episode":1}]}`
|
`","role":"episode","season":1,"episode":1}]}`
|
||||||
f := &fakeLLM{responses: []string{resp}}
|
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 {
|
if _, err := r.Recognize(context.Background(), in); err != nil {
|
||||||
t.Fatalf("Recognize: %v", err)
|
t.Fatalf("Recognize: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,15 +76,72 @@ func validateSchema(p *Plan, in Input) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// decide считает решение модели уверенности. В Ф2 метабазы выключены, а без
|
// decide считает решение модели уверенности (см. recognition.md). Авто —
|
||||||
// подтверждённого матча в базе авто-раскладка не делается (recognition.md),
|
// только если выполнено всё: подтверждённый единичный матч в базе; чистая
|
||||||
// поэтому Auto всегда false; здесь же копим структурные предупреждения и
|
// структурная валидация (для сериала — число серий бьётся с базой);
|
||||||
// расхождения с пред-парсом — они объясняют ревью человеку.
|
// согласованность с пред-парсом; самооценка LLM не ниже порога. Любая
|
||||||
func decide(p Plan, pre PreParse) Decision {
|
// невыполненная — причина ухода в review.
|
||||||
reasons := []string{"матч в базе не подтверждён (метабазы отключены в Ф2) → 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)...)
|
reasons = append(reasons, structuralWarnings(p)...)
|
||||||
|
|
||||||
|
if match != nil && p.Type == MediaSeries {
|
||||||
|
reasons = append(reasons, episodeCountWarnings(p, match.SeasonEpisodeCounts)...)
|
||||||
|
}
|
||||||
|
|
||||||
reasons = append(reasons, consistencyWarnings(p, pre)...)
|
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 — нарушения структуры плана (мягкие, не блокируют разбор).
|
// structuralWarnings — нарушения структуры плана (мягкие, не блокируют разбор).
|
||||||
|
|||||||
@@ -155,17 +155,71 @@ func TestConsistencyWarnings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDecide_AlwaysReview(t *testing.T) {
|
func TestDecide_MetadataDisabled(t *testing.T) {
|
||||||
p := Plan{Type: MediaMovie, Title: "X", Files: []PlanFile{{Role: RoleMain}}}
|
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}}
|
||||||
d := decide(p, PreParse{})
|
d := decide(p, PreParse{}, nil, false, 0.85)
|
||||||
if d.Auto {
|
if d.Auto {
|
||||||
t.Error("Ф2 decision must never be auto")
|
t.Error("без метабаз авто недопустимо")
|
||||||
}
|
}
|
||||||
if len(d.Reasons) == 0 || !strings.Contains(d.Reasons[0], "метабазы отключены") {
|
if len(d.Reasons) == 0 || !strings.Contains(d.Reasons[0], "метабазы отключены") {
|
||||||
t.Errorf("first reason should be DB match, got %v", d.Reasons)
|
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) {
|
func TestPreParse(t *testing.T) {
|
||||||
pre := preParse("The.Matrix.1999.1080p.BluRay.x264")
|
pre := preParse("The.Matrix.1999.1080p.BluRay.x264")
|
||||||
if pre.Year != 1999 {
|
if pre.Year != 1999 {
|
||||||
|
|||||||
+84
-22
@@ -112,18 +112,25 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
|
|||||||
|
|
||||||
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
|
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
|
||||||
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review.
|
// метабазы выключены → авто-раскладки не делаем, всегда уходим в 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)
|
planJSON, err := json.Marshal(res.Plan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.log.Error("recognize: marshal plan", "download_id", id, "err", err)
|
w.log.Error("recognize: marshal plan", "download_id", id, "err", err)
|
||||||
planJSON = []byte("{}")
|
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{
|
rec := &store.Recognition{
|
||||||
DownloadID: id,
|
DownloadID: id,
|
||||||
MediaType: store.NullString(string(res.Plan.Type)),
|
MediaType: store.NullString(string(res.Plan.Type)),
|
||||||
Title: store.NullString(res.Plan.Title),
|
Title: store.NullString(res.Plan.Title),
|
||||||
Provider: store.NullString("none"),
|
Provider: store.NullString(provider),
|
||||||
|
ProviderID: store.NullString(providerID),
|
||||||
Plan: store.NullString(string(planJSON)),
|
Plan: store.NullString(string(planJSON)),
|
||||||
RawLLM: store.NullString(res.Raw),
|
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)
|
w.log.Error("recognize: persist", "download_id", id, "err", err)
|
||||||
return
|
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, "", "")
|
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 создаёт хардлинки по текущему плану (с применёнными правками) и
|
// 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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("apply: %w", err)
|
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)
|
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 {
|
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()
|
batch := w.newID()
|
||||||
@@ -198,7 +239,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
|
|||||||
fl := make([]store.FileLink, 0, len(results))
|
fl := make([]store.FileLink, 0, len(results))
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
fl = append(fl, store.FileLink{
|
fl = append(fl, store.FileLink{
|
||||||
DownloadID: id,
|
DownloadID: d.ID,
|
||||||
ApplyBatchID: batch,
|
ApplyBatchID: batch,
|
||||||
SrcPath: r.Link.Src,
|
SrcPath: r.Link.Src,
|
||||||
DstPath: r.Link.Dst,
|
DstPath: r.Link.Dst,
|
||||||
@@ -208,21 +249,21 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
|
|||||||
}
|
}
|
||||||
if len(fl) > 0 {
|
if len(fl) > 0 {
|
||||||
if err := w.store.CreateFileLinks(ctx, fl); err != nil {
|
if err := w.store.CreateFileLinks(ctx, fl); err != nil {
|
||||||
return fmt.Errorf("apply: запись ссылок: %w", err)
|
return fmt.Errorf("запись ссылок: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if applyErr != nil {
|
if applyErr != nil {
|
||||||
if errors.Is(applyErr, layout.ErrCollision) {
|
if errors.Is(applyErr, layout.ErrCollision) {
|
||||||
w.transition(ctx, *d, store.StateReview, "collision", applyErr.Error())
|
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())
|
w.transition(ctx, *d, store.StateFailed, "apply", applyErr.Error())
|
||||||
return fmt.Errorf("apply: %w", applyErr)
|
return applyErr
|
||||||
}
|
}
|
||||||
|
|
||||||
w.transition(ctx, *d, store.StateDone, "", "")
|
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
|
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 {
|
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err == nil {
|
||||||
plan = applyOverrides(plan, overrides)
|
plan = applyOverrides(plan, overrides)
|
||||||
rd.Plan = plan
|
rd.Plan = plan
|
||||||
// Превью строим по относительным путям; ошибку игнорируем —
|
// Превью строим по относительным путям с provider-тегом; ошибку
|
||||||
// просто покажем причины без превью.
|
// игнорируем — просто покажем причины без превью.
|
||||||
if w.layouter != nil {
|
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
|
rd.Preview = links
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,24 +463,26 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
|
|||||||
return rd, nil
|
return rd, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// effectivePlan загружает текущий план и применяет правки (под mu).
|
// effectivePlan загружает текущий план, применяет правки и возвращает
|
||||||
func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, error) {
|
// provider-тег для имени папки (под mu).
|
||||||
|
func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, string, error) {
|
||||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return recognize.Plan{}, err
|
return recognize.Plan{}, "", err
|
||||||
}
|
}
|
||||||
if rec == nil || !rec.Plan.Valid {
|
if rec == nil || !rec.Plan.Valid {
|
||||||
return recognize.Plan{}, fmt.Errorf("нет плана распознавания")
|
return recognize.Plan{}, "", fmt.Errorf("нет плана распознавания")
|
||||||
}
|
}
|
||||||
var plan recognize.Plan
|
var plan recognize.Plan
|
||||||
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
|
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)
|
overrides, err := w.store.ListOverrides(ctx, id)
|
||||||
if err != nil {
|
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
|
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
|
// toLayoutPlan переводит план распознавания в план раскладки. srcPrefix
|
||||||
// (savePath) приклеивается к относительным путям файлов; пустой — оставляет
|
// (savePath) приклеивается к относительным путям файлов; пустой — оставляет
|
||||||
// относительные (для превью). Роли вне main/episode/subtitle отбрасываются.
|
// относительные (для превью). providerTag добавляется к имени папки. Роли
|
||||||
func toLayoutPlan(plan recognize.Plan, srcPrefix string) layout.Plan {
|
// вне main/episode/subtitle отбрасываются.
|
||||||
|
func toLayoutPlan(plan recognize.Plan, srcPrefix, providerTag string) layout.Plan {
|
||||||
lp := layout.Plan{
|
lp := layout.Plan{
|
||||||
Type: layout.MediaType(plan.Type),
|
Type: layout.MediaType(plan.Type),
|
||||||
Title: plan.Title,
|
Title: plan.Title,
|
||||||
Year: plan.Year,
|
Year: plan.Year,
|
||||||
|
ProviderTag: providerTag,
|
||||||
}
|
}
|
||||||
for _, f := range plan.Files {
|
for _, f := range plan.Files {
|
||||||
role, ok := mapRole(f.Role)
|
role, ok := mapRole(f.Role)
|
||||||
|
|||||||
@@ -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) {
|
func TestToLayoutPlan(t *testing.T) {
|
||||||
s, e := 1, 3
|
s, e := 1, 3
|
||||||
plan := recognize.Plan{
|
plan := recognize.Plan{
|
||||||
@@ -537,7 +611,7 @@ func TestToLayoutPlan(t *testing.T) {
|
|||||||
{Src: "sample.mkv", Role: "sample"},
|
{Src: "sample.mkv", Role: "sample"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
lp := toLayoutPlan(plan, "/d")
|
lp := toLayoutPlan(plan, "/d", "tmdbid-1")
|
||||||
if len(lp.Files) != 1 {
|
if len(lp.Files) != 1 {
|
||||||
t.Fatalf("want 1 linkable file, got %d", len(lp.Files))
|
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 {
|
if lp.Files[0].Role != layout.RoleEpisode {
|
||||||
t.Errorf("role = %q", lp.Files[0].Role)
|
t.Errorf("role = %q", lp.Files[0].Role)
|
||||||
}
|
}
|
||||||
|
if lp.ProviderTag != "tmdbid-1" {
|
||||||
|
t.Errorf("provider tag = %q", lp.ProviderTag)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
Тип: <b>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</b>
|
Тип: <b>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</b>
|
||||||
· Название: <b>{{.Title}}</b>{{if .OriginalTitle}} <small>({{.OriginalTitle}})</small>{{end}}
|
· Название: <b>{{.Title}}</b>{{if .OriginalTitle}} <small>({{.OriginalTitle}})</small>{{end}}
|
||||||
{{if .Year}}· Год: <b>{{.Year}}</b>{{end}}
|
{{if .Year}}· Год: <b>{{.Year}}</b>{{end}}
|
||||||
|
{{if .Provider}}· База: <b>{{.Provider}}</b> {{.ProviderID}}{{end}}
|
||||||
</p>
|
</p>
|
||||||
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type">
|
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type">
|
||||||
Переключить тип:
|
Переключить тип:
|
||||||
|
|||||||
Reference in New Issue
Block a user