Добавил поиск метаданных по каталогам

This commit is contained in:
2026-06-14 15:21:01 +03:00
parent 9c1b178e46
commit 5087f35861
21 changed files with 1435 additions and 72 deletions
+1
View File
@@ -4,6 +4,7 @@
# Реальный конфиг (секреты) и локальная БД # Реальный конфиг (секреты) и локальная БД
/config.toml /config.toml
/.env
*.db *.db
*.db-wal *.db-wal
*.db-shm *.db-shm
+44 -2
View File
@@ -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
}
+6
View File
@@ -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)
} }
-4
View File
@@ -1,4 +0,0 @@
// Package metadata — интерфейс баз метаданных и клиенты TMDB/TVDB (опц.).
//
// Заглушка: реализация в фазе Ф4 (см. docs/specs/architecture.md).
package metadata
+89
View File
@@ -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)
}
+53
View File
@@ -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("ожидались данные о числе серий по сезонам")
}
}
+45
View File
@@ -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)
}
+132
View File
@@ -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
}
+109
View File
@@ -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")
}
}
+185
View File
@@ -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
}
+132
View File
@@ -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")
}
}
+1 -1
View File
@@ -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{
+118
View File
@@ -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())
}
+155
View File
@@ -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)
}
}
+46 -11
View File
@@ -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
+26 -12
View File
@@ -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)
} }
+64 -7
View File
@@ -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 — нарушения структуры плана (мягкие, не блокируют разбор).
+58 -4
View File
@@ -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
View File
@@ -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)
+78 -1
View File
@@ -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)
}
} }
+1
View File
@@ -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">
Переключить тип: Переключить тип: