display_name: слоистое разрешение полей + сохранение режиссёра из контекста

Единый источник полей отображаемого имени и один рендер полного ярлыка на
всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный
«Название (режиссёр, год). Сезон N» но выбрасывал структуру, а перелив по
распознаванию — усечённый «Title (Year)».

- Слоистое разрешение скаляров имени: override → recognition(+match) →
  новый базовый слой «контекст» (download.parsed_context, JSON naming.Fields).
- naming: публичные Fields/Label/Derive, вынесен единый рендер; удалён
  FormatTitleYear. Сводка сезонов вынесена в recognize.SeasonSummary.
- Режиссёр из метабазы (решение A2): TMDB/TVDB credits через опциональный
  metadata.DirectorProvider; авто-матч кладёт в plan.Director, ручной выбор
  кандидата тянет credits и пиннит ovrDirector. Метабаза бьёт контекст.
- refreshDisplayNameLocked строит полный ярлык из эффективных полей;
  инфо-панель ревью показывает загруженного режиссёра.
- Миграция 0011_parsed_context + ER-схема. Всё косметика: на пути/раскладку
  не влияет, приём/вывод имени не валятся (best-effort).

Закрывает беклог-задачу «Кнопка „Обновить имя“: полный формат ярлыка».
OpenSpec: archive/2026-07-11-field-resolution-display-name (ingest,
recognition, metadata-match, review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-11 11:50:08 +03:00
co-authored by Claude Opus 4.8
parent 9472bfdd83
commit 02d4ecc2aa
40 changed files with 1536 additions and 311 deletions
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"strconv"
"time"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
@@ -141,7 +142,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
view.RecTitle = rd.Plan.Title
view.OriginalTitle = rd.Plan.OriginalTitle
if view.IsSeries {
view.Season = seasonSummary(rd.Plan)
view.Season = recognize.SeasonSummary(rd.Plan)
}
view.Year = rd.Plan.Year
// Ручное обновление имени доступно, когда есть распознанное название,
-67
View File
@@ -1,10 +1,6 @@
package httpapi
import (
"sort"
"strconv"
"strings"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/recognize"
)
@@ -40,69 +36,6 @@ func buildFileRows(plan recognize.Plan, preview []layout.Link) []fileRow {
return rows
}
// seasonSummary собирает верхнеуровневую сводку сезонов сериальной раздачи по
// эпизодным файлам плана. Сезон задан на файле (мультисезонные паки), поэтому
// сводим множество различных сезонов; Season nil/0 — спецвыпуски. Примеры:
// «Сезон 2», «Сезоны 1–3», «Сезоны 1, 3–4», «Спецвыпуски», «Сезоны 1–2, спецвыпуски».
func seasonSummary(plan recognize.Plan) string {
seen := map[int]bool{}
specials := false
for _, f := range plan.Files {
if f.Role != recognize.RoleEpisode {
continue
}
n := 0
if f.Season != nil {
n = *f.Season
}
if n <= 0 {
specials = true
continue
}
seen[n] = true
}
nums := make([]int, 0, len(seen))
for n := range seen {
nums = append(nums, n)
}
sort.Ints(nums)
var parts []string
switch {
case len(nums) == 1:
parts = append(parts, "Сезон "+strconv.Itoa(nums[0]))
case len(nums) > 1:
parts = append(parts, "Сезоны "+seasonRanges(nums))
}
if specials {
if len(parts) == 0 {
parts = append(parts, "Спецвыпуски")
} else {
parts = append(parts, "спецвыпуски")
}
}
return strings.Join(parts, ", ")
}
// seasonRanges схлопывает возрастающие номера сезонов в диапазоны:
// [1,2,3] → «13», [1,3,4] → «1, 34».
func seasonRanges(nums []int) string {
var out []string
for i := 0; i < len(nums); {
j := i
for j+1 < len(nums) && nums[j+1] == nums[j]+1 {
j++
}
if j == i {
out = append(out, strconv.Itoa(nums[i]))
} else {
out = append(out, strconv.Itoa(nums[i])+""+strconv.Itoa(nums[j]))
}
i = j + 1
}
return strings.Join(out, ", ")
}
// roleLabel — человекочитаемая роль файла раскладки.
func roleLabel(role string) string {
switch role {
-53
View File
@@ -1,53 +0,0 @@
package httpapi
import (
"testing"
"git.vakhrushev.me/av/jellybit/internal/recognize"
)
func TestSeasonSummary(t *testing.T) {
// ep — эпизодный файл с заданным (или nil) сезоном.
ep := func(season *int) recognize.PlanFile {
return recognize.PlanFile{Role: recognize.RoleEpisode, Season: season}
}
n := func(v int) *int { return &v }
cases := []struct {
name string
files []recognize.PlanFile
want string
}{
{"пусто", nil, ""},
{"один сезон", []recognize.PlanFile{ep(n(2)), ep(n(2))}, "Сезон 2"},
{"диапазон", []recognize.PlanFile{ep(n(1)), ep(n(2)), ep(n(3))}, "Сезоны 13"},
{"разрыв", []recognize.PlanFile{ep(n(1)), ep(n(3)), ep(n(4))}, "Сезоны 1, 34"},
{"несортированный вход", []recognize.PlanFile{ep(n(3)), ep(n(1)), ep(n(2))}, "Сезоны 13"},
{"только спецвыпуски (nil)", []recognize.PlanFile{ep(nil)}, "Спецвыпуски"},
{"только спецвыпуски (0)", []recognize.PlanFile{ep(n(0))}, "Спецвыпуски"},
{"сезоны и спецвыпуски", []recognize.PlanFile{ep(n(1)), ep(n(2)), ep(nil)}, "Сезоны 1–2, спецвыпуски"},
{"один сезон и спецвыпуски", []recognize.PlanFile{ep(n(1)), ep(nil)}, "Сезон 1, спецвыпуски"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := seasonSummary(recognize.Plan{Files: c.files})
if got != c.want {
t.Errorf("seasonSummary = %q, want %q", got, c.want)
}
})
}
}
// Не-эпизодные файлы (main/subtitle/…) не влияют на сводку сезонов.
func TestSeasonSummary_IgnoresNonEpisodes(t *testing.T) {
s := 2
plan := recognize.Plan{Files: []recognize.PlanFile{
{Role: recognize.RoleEpisode, Season: &s},
{Role: recognize.RoleMain},
{Role: recognize.RoleSubtitle},
{Role: recognize.RoleIgnore},
}}
if got := seasonSummary(plan); got != "Сезон 2" {
t.Errorf("seasonSummary = %q, want «Сезон 2»", got)
}
}
+4 -1
View File
@@ -9,6 +9,7 @@ import (
"strings"
"git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
@@ -45,6 +46,7 @@ type reviewView struct {
IsSeries bool
Title string
OriginalTitle string
Director string // режиссёр эффективного источника (пусто — неизвестен)
Year int
SeasonSummary string // сводка сезонов для сериала (пусто для фильма)
Provider string
@@ -117,9 +119,10 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
view.IsSeries = rd.Plan.Type == "series"
view.Title = rd.Plan.Title
view.OriginalTitle = rd.Plan.OriginalTitle
view.Director = rd.Plan.Director
view.Year = rd.Plan.Year
if view.IsSeries {
view.SeasonSummary = seasonSummary(rd.Plan)
view.SeasonSummary = recognize.SeasonSummary(rd.Plan)
}
view.Reasons = rec.ReasonList()
switch rd.Provider {
+9
View File
@@ -51,3 +51,12 @@ type Provider interface {
// (ключ — номер сезона). Нужен для валидации полноты сезон-пака.
SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error)
}
// DirectorProvider — опциональная способность провайдера отдать режиссёра записи
// по её нативному id (TMDB/TVDB credits; TVMaze не поддерживает и интерфейс не
// реализует). Проверяется type assertion'ом. Режиссёр — недоверенное
// косметическое поле для вывода отображаемого имени; выборка best-effort, пустая
// строка — режиссёр неизвестен.
type DirectorProvider interface {
Director(ctx context.Context, mt MediaType, id string) (string, error)
}
+45
View File
@@ -141,6 +141,51 @@ func (t *TMDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int,
return out, nil
}
type tmdbCreditsResp struct {
Crew []struct {
Job string `json:"job"`
Name string `json:"name"`
} `json:"crew"`
}
type tmdbTVDetailsResp struct {
CreatedBy []struct {
Name string `json:"name"`
} `json:"created_by"`
}
// Director возвращает режиссёра: для фильма — из crew (job "Director"), для
// сериала — создателей (created_by). Пусто — режиссёр не указан. Best-effort:
// вызывающий гасит ошибку.
func (t *TMDB) Director(ctx context.Context, mt MediaType, id string) (string, error) {
params := url.Values{"api_key": {t.apiKey}, "language": {t.language}}
if mt == Series {
var resp tmdbTVDetailsResp
if err := getJSON(ctx, t.hc, t.log, logging.ServiceTMDB, "tv",
t.baseURL+"/tv/"+url.PathEscape(id)+"?"+params.Encode(), nil, &resp); err != nil {
return "", fmt.Errorf("tmdb tv %s: %w", id, err)
}
names := make([]string, 0, len(resp.CreatedBy))
for _, c := range resp.CreatedBy {
if strings.TrimSpace(c.Name) != "" {
names = append(names, c.Name)
}
}
return strings.Join(names, ", "), nil
}
var resp tmdbCreditsResp
if err := getJSON(ctx, t.hc, t.log, logging.ServiceTMDB, "movie/credits",
t.baseURL+"/movie/"+url.PathEscape(id)+"/credits?"+params.Encode(), nil, &resp); err != nil {
return "", fmt.Errorf("tmdb movie credits %s: %w", id, err)
}
for _, c := range resp.Crew {
if c.Job == "Director" && strings.TrimSpace(c.Name) != "" {
return c.Name, nil
}
}
return "", nil
}
// yearOf достаёт год из даты вида "1999-03-31".
func yearOf(date string) int {
if len(date) < 4 {
+60
View File
@@ -152,3 +152,63 @@ func TestNewTMDB_RequiresKey(t *testing.T) {
t.Fatal("want error without api_key")
}
}
func TestTMDB_DirectorMovie(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/movie/603/credits" {
t.Errorf("path = %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"crew":[
{"job":"Producer","name":"Joel Silver"},
{"job":"Director","name":"Lana Wachowski"}
]}`))
}))
defer srv.Close()
got, err := newTMDB(t, srv.URL).Director(context.Background(), Movie, "603")
if err != nil {
t.Fatalf("Director: %v", err)
}
if got != "Lana Wachowski" {
t.Errorf("director = %q, want «Lana Wachowski»", got)
}
}
func TestTMDB_DirectorSeriesCreatedBy(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(`{"created_by":[{"name":"Noah Hawley"}]}`))
}))
defer srv.Close()
got, err := newTMDB(t, srv.URL).Director(context.Background(), Series, "60622")
if err != nil {
t.Fatalf("Director: %v", err)
}
if got != "Noah Hawley" {
t.Errorf("director = %q, want «Noah Hawley»", got)
}
}
// Нет режиссёра в crew → пустая строка (best-effort, не ошибка).
func TestTMDB_DirectorAbsent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"crew":[{"job":"Producer","name":"X"}]}`))
}))
defer srv.Close()
got, err := newTMDB(t, srv.URL).Director(context.Background(), Movie, "1")
if err != nil {
t.Fatalf("Director: %v", err)
}
if got != "" {
t.Errorf("director = %q, want пусто", got)
}
}
// TMDB реализует опциональный DirectorProvider.
func TestTMDB_ImplementsDirectorProvider(t *testing.T) {
var _ DirectorProvider = (*TMDB)(nil)
}
+29
View File
@@ -190,6 +190,35 @@ type tvdbExtendedResp struct {
} `json:"data"`
}
type tvdbCharactersResp struct {
Data struct {
Characters []struct {
PeopleType string `json:"peopleType"`
PersonName string `json:"personName"`
} `json:"characters"`
} `json:"data"`
}
// Director возвращает режиссёра из расширенных данных записи (characters с
// peopleType "Director"). Пусто — режиссёр не указан. Best-effort: вызывающий
// гасит ошибку.
func (t *TVDB) Director(ctx context.Context, mt MediaType, id string) (string, error) {
kind := "series"
if mt == Movie {
kind = "movies"
}
var resp tvdbCharactersResp
if err := t.get(ctx, kind+"/extended", "/"+kind+"/"+url.PathEscape(id)+"/extended", &resp); err != nil {
return "", fmt.Errorf("tvdb %s %s: %w", kind, id, err)
}
for _, c := range resp.Data.Characters {
if c.PeopleType == "Director" && strings.TrimSpace(c.PersonName) != "" {
return c.PersonName, nil
}
}
return "", nil
}
// SeasonEpisodeCounts считает число серий по сезонам из расширенных данных.
func (t *TVDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
var resp tvdbExtendedResp
+66 -52
View File
@@ -7,6 +7,11 @@
// режиссёра/сезона), при неудаче — алгоритмический фолбек без сети. Любой
// сбой деградирует к пустой строке: приём загрузки никогда не падает из-за
// вывода имени.
//
// Извлечённая структура ([Fields]) — базовый (наименее доверенный) слой
// источника полей имени: её сохраняют у загрузки (parsed_context) и позже
// переиспользуют при обновлении display_name (поле, которого нет в
// распознавании/матче, — например режиссёр из контекста — не теряется).
package naming
import (
@@ -22,24 +27,34 @@ import (
// maxNameLen — ограничение длины отображаемого имени (символов/рун).
const maxNameLen = 200
// mediaType — вид контента в извлечённой структуре.
type mediaType string
const (
typeMovie mediaType = "movie"
typeSeries mediaType = "series"
typeMovie = "movie"
typeSeries = "series"
)
// extracted — структура имени, извлечённая из контекста (схема ответа LLM).
// Year и Director опциональны (пустое значение → в ярлык не попадают).
type extracted struct {
Type mediaType `json:"type"`
Title string `json:"title"`
OriginalTitle string `json:"original_title"`
Year int `json:"year"`
Director string `json:"director"`
Season *int `json:"season"`
IsRussian bool `json:"is_russian"`
// Fields — скалярные поля имени, извлечённые из одного источника (контекст).
// Это же схема ответа LLM и схема хранения parsed_context. Year/Director/Season
// опциональны (пустые в ярлык не попадают).
type Fields struct {
Type string `json:"type"` // "movie"|"series"
Title string `json:"title"`
OriginalTitle string `json:"original_title"`
Year int `json:"year"`
Director string `json:"director"`
Season *int `json:"season"`
IsRussian bool `json:"is_russian"`
}
// SeasonLabel — сводка сезона из контекстного скаляра: «Сезон N» для сериала с
// заданным номером, иначе пусто. Для контекста сезон скалярный (в отличие от
// плана распознавания, где он per-file и сводится recognize.SeasonSummary).
// Используется как fallback-слой сводки сезонов, когда план распознавания её не
// даёт.
func (f Fields) SeasonLabel() string {
if f.Type == typeSeries && f.Season != nil && *f.Season > 0 {
return "Сезон " + strconv.Itoa(*f.Season)
}
return ""
}
// Namer выводит отображаемое имя. provider может быть nil — тогда работает
@@ -65,72 +80,71 @@ func New(provider llm.Provider, attempts int, logger *slog.Logger) *Namer {
return &Namer{provider: provider, attempts: attempts, log: logger}
}
// DeriveName выводит отображаемое имя из контекста. hint — подсказка из
// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает
// "" если имя получить не удалось (тогда вызывающий не задаёт rename).
func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string {
// Derive выводит отображаемое имя и извлечённую структуру. hint — подсказка из
// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает имя
// ("" если вывести не удалось вызывающий не задаёт rename) и извлечённую
// структуру с ok=true, если LLM дал валидные поля (тогда их JSON сохраняют как
// parsed_context). Фолбек полей не даёт (ok=false): его выход — только строка.
func (n *Namer) Derive(ctx context.Context, contextText, hint string) (name string, fields Fields, ok bool) {
// Нет ни контекста, ни подсказки — выводить имя не из чего. LLM на пустом
// входе способен лишь галлюцинировать (наблюдалось «Unknown»), поэтому его не
// зовём: имя считается не выведенным, вызывающий добавит загрузку без rename.
if strings.TrimSpace(contextText) == "" && strings.TrimSpace(hint) == "" {
return ""
return "", Fields{}, false
}
if n.provider != nil {
if ex, ok := n.extractViaLLM(ctx, contextText, hint); ok {
if name := render(ex); name != "" {
return name
if ex, extracted := n.extractViaLLM(ctx, contextText, hint); extracted {
if label := render(ex); label != "" {
return label, ex, true
}
}
}
return fallbackName(contextText, hint)
return fallbackName(contextText, hint), Fields{}, false
}
// FormatTitleYear собирает детерминированный ярлык «Title (Year)» из уже
// распознанного названия — без сети и LLM (перелив канонического имени после
// распознавания). Год опционален; имя очищается от управляющих символов и
// обрезается по длине. Пустой title → пустая строка. В отличие от render это
// НЕ формат add-шага (там ещё режиссёр и сезон): у перелива их нет.
func FormatTitleYear(title string, year int) string {
// DeriveName — тонкая обёртка над Derive, когда структура не нужна (возвращает
// только имя).
func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string {
name, _, _ := n.Derive(ctx, contextText, hint)
return name
}
// Label собирает полный детерминированный ярлык display_name из эффективных
// полей: «Название (режиссёр, год)», для сериала — хвост «. <сводка сезонов>».
// Все части, кроме названия, опциональны и выпадают, если пусты. seasonSummary —
// уже готовая строка сводки (для плана — recognize.SeasonSummary; для контекста —
// «Сезон N»). Имя очищается от управляющих символов и обрезается по длине.
// Пустое название → пустая строка. Единый рендер для шага добавления и перелива
// имени по распознаванию, чтобы формат ярлыка совпадал на всех путях.
func Label(title, director string, year int, seasonSummary string) string {
title = sanitize(title)
if title == "" {
return ""
}
if year > 0 {
title += " (" + strconv.Itoa(year) + ")"
}
return truncate(title, maxNameLen)
}
// render собирает из структуры короткий ярлык:
// - movie: "Title (Director, Year)" — режиссёр и год опциональны;
// - series: то же + ". Сезон N", если сезон задан.
//
// Имя очищается от управляющих символов/переводов строк и обрезается по
// длине. Пустой Title → пустая строка.
func render(ex extracted) string {
title := sanitize(ex.Title)
if title == "" {
return ""
}
var paren []string
if d := sanitize(ex.Director); d != "" {
if d := sanitize(director); d != "" {
paren = append(paren, d)
}
if ex.Year > 0 {
paren = append(paren, strconv.Itoa(ex.Year))
if year > 0 {
paren = append(paren, strconv.Itoa(year))
}
name := title
if len(paren) > 0 {
name += " (" + strings.Join(paren, ", ") + ")"
}
if ex.Type == typeSeries && ex.Season != nil && *ex.Season > 0 {
name += ". Сезон " + strconv.Itoa(*ex.Season)
if s := sanitize(seasonSummary); s != "" {
name += ". " + s
}
return truncate(name, maxNameLen)
}
// render собирает ярлык из извлечённой структуры (контекст) через общий Label.
func render(f Fields) string {
return Label(f.Title, f.Director, f.Year, f.SeasonLabel())
}
// sanitize убирает управляющие символы и переводы строк, схлопывает пробелы.
func sanitize(s string) string {
s = strings.Map(func(r rune) rune {
+22 -12
View File
@@ -77,22 +77,32 @@ func TestDeriveNameHintPresentSkipsGuard(t *testing.T) {
}
}
func TestFormatTitleYear(t *testing.T) {
func TestLabel(t *testing.T) {
cases := []struct {
title string
year int
want string
name string
title string
director string
year int
season string
want string
}{
{"Harold and the Purple Crayon", 2024, "Harold and the Purple Crayon (2024)"},
{"Брат", 0, "Брат"},
{" Fargo\n", 2015, "Fargo (2015)"},
{"", 2024, ""},
{" ", 0, ""},
{"title only", "Брат", "", 0, "", "Брат"},
{"title+year", "Fargo", "", 2015, "", "Fargo (2015)"},
{"title+director+year", "Дюна", "Дени Вильнёв", 2024, "", "Дюна (Дени Вильнёв, 2024)"},
{"director only", "Брат", "Алексей Балабанов", 0, "", "Брат (Алексей Балабанов)"},
{"series one season", "Сёгун", "", 2024, "Сезон 2", "Сёгун (2024). Сезон 2"},
{"series multi season", "Фарго", "", 0, "Сезоны 13", "Фарго. Сезоны 13"},
{"series specials", "Рик и Морти", "", 0, "Спецвыпуски", "Рик и Морти. Спецвыпуски"},
{"empty title", " ", "Кто-то", 2024, "Сезон 1", ""},
{"sanitized", " Fargo\n", "", 2015, "", "Fargo (2015)"},
}
for _, c := range cases {
if got := FormatTitleYear(c.title, c.year); got != c.want {
t.Errorf("FormatTitleYear(%q, %d) = %q, want %q", c.title, c.year, got, c.want)
}
t.Run(c.name, func(t *testing.T) {
if got := Label(c.title, c.director, c.year, c.season); got != c.want {
t.Errorf("Label(%q, %q, %d, %q) = %q, want %q",
c.title, c.director, c.year, c.season, got, c.want)
}
})
}
}
+9 -9
View File
@@ -40,10 +40,10 @@ const systemPrompt = `Ты извлекаешь из текста о торре
// Транспортные ошибки провайдера (сеть/429/5xx) гасятся внутри llm.Provider;
// здесь повторяем только переразбор невалидного ответа. ok=false, если за
// все попытки валидного результата не получено.
func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (extracted, bool) {
func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (Fields, bool) {
contextText = strings.TrimSpace(contextText)
if contextText == "" {
return extracted{}, false
return Fields{}, false
}
user := "Контекст раздачи:\n" + contextText
@@ -68,7 +68,7 @@ func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (ex
// Транспортная ошибка/таймаут залогирована клиентом LLM (ext.*);
// здесь — доменное решение «уходим в фолбек, приём не валим».
log.Debug("naming llm failed, using fallback")
return extracted{}, false
return Fields{}, false
}
ex, perr := parseExtracted(resp.Content)
@@ -81,22 +81,22 @@ func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (ex
llm.Message{Role: llm.RoleUser, Content: "Ответ невалиден: " + perr.Error() +
". Верни строго один JSON-объект по схеме, без пояснений."})
}
return extracted{}, false
return Fields{}, false
}
// parseExtracted вытаскивает JSON-объект из ответа и валидирует минимум:
// непустой title. Тип по умолчанию — movie.
func parseExtracted(raw string) (extracted, error) {
func parseExtracted(raw string) (Fields, error) {
jsonStr, err := llm.ExtractJSONObject(raw)
if err != nil {
return extracted{}, err
return Fields{}, err
}
var ex extracted
var ex Fields
if err := json.Unmarshal([]byte(jsonStr), &ex); err != nil {
return extracted{}, fmt.Errorf("naming: unmarshal: %w", err)
return Fields{}, fmt.Errorf("naming: unmarshal: %w", err)
}
if strings.TrimSpace(ex.Title) == "" {
return extracted{}, fmt.Errorf("naming: empty title")
return Fields{}, fmt.Errorf("naming: empty title")
}
if ex.Type != typeMovie && ex.Type != typeSeries {
ex.Type = typeMovie
+11 -11
View File
@@ -10,52 +10,52 @@ func ptr(i int) *int { return &i }
func TestRender(t *testing.T) {
tests := []struct {
name string
in extracted
in Fields
want string
}{
{
name: "movie with director and year",
in: extracted{Type: typeMovie, Title: "Дюна: Часть вторая", Director: "Дени Вильнёв", Year: 2024},
in: Fields{Type: typeMovie, Title: "Дюна: Часть вторая", Director: "Дени Вильнёв", Year: 2024},
want: "Дюна: Часть вторая (Дени Вильнёв, 2024)",
},
{
name: "movie with year only",
in: extracted{Type: typeMovie, Title: "Дюна: Часть вторая", Year: 2024},
in: Fields{Type: typeMovie, Title: "Дюна: Часть вторая", Year: 2024},
want: "Дюна: Часть вторая (2024)",
},
{
name: "movie with director only",
in: extracted{Type: typeMovie, Title: "Брат", Director: "Алексей Балабанов"},
in: Fields{Type: typeMovie, Title: "Брат", Director: "Алексей Балабанов"},
want: "Брат (Алексей Балабанов)",
},
{
name: "movie without director and year",
in: extracted{Type: typeMovie, Title: "Брат"},
in: Fields{Type: typeMovie, Title: "Брат"},
want: "Брат",
},
{
name: "series with season",
in: extracted{Type: typeSeries, Title: "Сёгун", Year: 2024, Season: ptr(2)},
in: Fields{Type: typeSeries, Title: "Сёгун", Year: 2024, Season: ptr(2)},
want: "Сёгун (2024). Сезон 2",
},
{
name: "series without season",
in: extracted{Type: typeSeries, Title: "Сёгун", Year: 2024},
in: Fields{Type: typeSeries, Title: "Сёгун", Year: 2024},
want: "Сёгун (2024)",
},
{
name: "series season zero is omitted",
in: extracted{Type: typeSeries, Title: "Сёгун", Season: ptr(0)},
in: Fields{Type: typeSeries, Title: "Сёгун", Season: ptr(0)},
want: "Сёгун",
},
{
name: "empty title yields empty name",
in: extracted{Type: typeMovie, Title: " ", Year: 2024},
in: Fields{Type: typeMovie, Title: " ", Year: 2024},
want: "",
},
{
name: "control chars and newlines are sanitized",
in: extracted{Type: typeMovie, Title: "Дюна\n\tЧасть\x00 вторая", Year: 2024},
in: Fields{Type: typeMovie, Title: "Дюна\n\tЧасть\x00 вторая", Year: 2024},
want: "Дюна Часть вторая (2024)",
},
}
@@ -70,7 +70,7 @@ func TestRender(t *testing.T) {
func TestRenderTruncates(t *testing.T) {
long := strings.Repeat("я", maxNameLen+50)
got := render(extracted{Type: typeMovie, Title: long})
got := render(Fields{Type: typeMovie, Title: long})
if n := len([]rune(got)); n > maxNameLen {
t.Errorf("длина имени = %d рун, want <= %d", n, maxNameLen)
}
+35
View File
@@ -2,6 +2,7 @@ package recognize
import (
"context"
"log/slog"
"strings"
"unicode"
@@ -143,10 +144,44 @@ func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c meta
ProviderID: pid,
Title: c.Title,
Year: c.Year,
Director: directorOf(ctx, r.log, p, mt, c.ID),
SeasonEpisodeCounts: counts,
}
}
// directorOf тянет режиссёра из провайдера по нативному id, если он умеет
// (DirectorProvider). Best-effort: ошибка/неподдержка → пусто, распознавание не
// валится. Режиссёр — недоверенное косметическое поле.
func directorOf(ctx context.Context, log *slog.Logger, p metadata.Provider, mt metadata.MediaType, id string) string {
dp, ok := p.(metadata.DirectorProvider)
if !ok {
return ""
}
d, err := dp.Director(ctx, mt, id)
if err != nil {
logctx.FromOr(ctx, log).Debug("metadata director skipped", "provider", p.Name(), "id", id)
return ""
}
return d
}
// Director тянет режиссёра выбранного источника по (provider, id) — точка входа
// для ручного выбора кандидата в ревью (см. worker). Ищет провайдера поиска по
// имени; best-effort — нет провайдера/не умеет/ошибка → пусто. mt — тип из плана.
func (r *Recognizer) Director(ctx context.Context, mt MediaType, provider, providerID string) string {
metaType := metadata.Movie
if mt == MediaSeries {
metaType = metadata.Series
}
for _, p := range r.providers {
if p.Name() != provider {
continue
}
return directorOf(ctx, r.log, p, metaType, providerID)
}
return ""
}
// CandidateTag — провайдер и id для тега папки Jellyfin: внешний (из
// TagProvider/TagID, напр. TVMaze → tvdb/imdb), если есть, иначе сам провайдер
// поиска. Используется и в матче, и при сохранении кандидатов.
+17 -8
View File
@@ -80,14 +80,19 @@ type PlanFile struct {
// Plan — структурированный результат распознавания (схема ответа LLM).
type Plan struct {
Type MediaType `json:"type"`
Title string `json:"title"`
OriginalTitle string `json:"original_title,omitempty"`
Year int `json:"year,omitempty"`
ProviderHint string `json:"provider_hint,omitempty"`
Files []PlanFile `json:"files"`
Confidence float64 `json:"confidence"`
Notes string `json:"notes,omitempty"`
Type MediaType `json:"type"`
Title string `json:"title"`
OriginalTitle string `json:"original_title,omitempty"`
Year int `json:"year,omitempty"`
// Director — режиссёр (опц.). LLM его НЕ заполняет и не валидируется по нему;
// его вкладывает подтверждённый матч метабазы (buildMatch) или закреплённый в
// ревью источник (override). Недоверенное косметическое поле для вывода
// отображаемого имени; в plan-санитайзинг не входит (чистится на рендере).
Director string `json:"director,omitempty"`
ProviderHint string `json:"provider_hint,omitempty"`
Files []PlanFile `json:"files"`
Confidence float64 `json:"confidence"`
Notes string `json:"notes,omitempty"`
}
// PreParse — черновой разбор имени релиза (go-ptn).
@@ -111,6 +116,7 @@ type Match struct {
ProviderID string // официальный id
Title string // каноническое название
Year int // каноничный год
Director string // режиссёр (best-effort из credits; пусто — нет)
SeasonEpisodeCounts map[int]int // число серий по сезонам (для сериала)
}
@@ -245,6 +251,9 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
if match.Year != 0 {
plan.Year = match.Year
}
if match.Director != "" {
plan.Director = match.Director
}
}
dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold)
+72
View File
@@ -0,0 +1,72 @@
package recognize
import (
"sort"
"strconv"
"strings"
)
// SeasonSummary собирает верхнеуровневую сводку сезонов сериальной раздачи по
// эпизодным файлам плана. Сезон задан на файле (мультисезонные паки), поэтому
// сводим множество различных сезонов; Season nil/0 — спецвыпуски. Примеры:
// «Сезон 2», «Сезоны 1–3», «Сезоны 1, 3–4», «Спецвыпуски», «Сезоны 1–2, спецвыпуски».
// Пустая строка — эпизодных файлов нет (нечего сводить). Общий хелпер для веб-UI
// (сводка сезонов на карточке/ревью) и для ярлыка display_name (naming).
func SeasonSummary(plan Plan) string {
seen := map[int]bool{}
specials := false
for _, f := range plan.Files {
if f.Role != RoleEpisode {
continue
}
n := 0
if f.Season != nil {
n = *f.Season
}
if n <= 0 {
specials = true
continue
}
seen[n] = true
}
nums := make([]int, 0, len(seen))
for n := range seen {
nums = append(nums, n)
}
sort.Ints(nums)
var parts []string
switch {
case len(nums) == 1:
parts = append(parts, "Сезон "+strconv.Itoa(nums[0]))
case len(nums) > 1:
parts = append(parts, "Сезоны "+seasonRanges(nums))
}
if specials {
if len(parts) == 0 {
parts = append(parts, "Спецвыпуски")
} else {
parts = append(parts, "спецвыпуски")
}
}
return strings.Join(parts, ", ")
}
// seasonRanges схлопывает возрастающие номера сезонов в диапазоны:
// [1,2,3] → «13», [1,3,4] → «1, 34».
func seasonRanges(nums []int) string {
var out []string
for i := 0; i < len(nums); {
j := i
for j+1 < len(nums) && nums[j+1] == nums[j]+1 {
j++
}
if j == i {
out = append(out, strconv.Itoa(nums[i]))
} else {
out = append(out, strconv.Itoa(nums[i])+""+strconv.Itoa(nums[j]))
}
i = j + 1
}
return strings.Join(out, ", ")
}
+49
View File
@@ -0,0 +1,49 @@
package recognize
import "testing"
func TestSeasonSummary(t *testing.T) {
// ep — эпизодный файл с заданным (или nil) сезоном.
ep := func(season *int) PlanFile {
return PlanFile{Role: RoleEpisode, Season: season}
}
n := func(v int) *int { return &v }
cases := []struct {
name string
files []PlanFile
want string
}{
{"пусто", nil, ""},
{"один сезон", []PlanFile{ep(n(2)), ep(n(2))}, "Сезон 2"},
{"диапазон", []PlanFile{ep(n(1)), ep(n(2)), ep(n(3))}, "Сезоны 13"},
{"разрыв", []PlanFile{ep(n(1)), ep(n(3)), ep(n(4))}, "Сезоны 1, 34"},
{"несортированный вход", []PlanFile{ep(n(3)), ep(n(1)), ep(n(2))}, "Сезоны 13"},
{"только спецвыпуски (nil)", []PlanFile{ep(nil)}, "Спецвыпуски"},
{"только спецвыпуски (0)", []PlanFile{ep(n(0))}, "Спецвыпуски"},
{"сезоны и спецвыпуски", []PlanFile{ep(n(1)), ep(n(2)), ep(nil)}, "Сезоны 1–2, спецвыпуски"},
{"один сезон и спецвыпуски", []PlanFile{ep(n(1)), ep(nil)}, "Сезон 1, спецвыпуски"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := SeasonSummary(Plan{Files: c.files})
if got != c.want {
t.Errorf("SeasonSummary = %q, want %q", got, c.want)
}
})
}
}
// Не-эпизодные файлы (main/subtitle/…) не влияют на сводку сезонов.
func TestSeasonSummary_IgnoresNonEpisodes(t *testing.T) {
s := 2
plan := Plan{Files: []PlanFile{
{Role: RoleEpisode, Season: &s},
{Role: RoleMain},
{Role: RoleSubtitle},
{Role: RoleIgnore},
}}
if got := SeasonSummary(plan); got != "Сезон 2" {
t.Errorf("SeasonSummary = %q, want «Сезон 2»", got)
}
}
+26 -8
View File
@@ -173,14 +173,19 @@ type Infohash struct {
// Download — строка таблицы download.
type Download struct {
ID string `db:"id"` // ULID (lowercase), публичный ключ домена
SourceType SourceType `db:"source_type"`
SourceRef string `db:"source_ref"`
DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI
Context string `db:"context"`
State State `db:"state"`
ErrorCode sql.NullString `db:"error_code"`
ErrorMsg sql.NullString `db:"error_msg"`
ID string `db:"id"` // ULID (lowercase), публичный ключ домена
SourceType SourceType `db:"source_type"`
SourceRef string `db:"source_ref"`
DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI
Context string `db:"context"`
// ParsedContext — извлечённая на шаге добавления структура имени (naming, JSON:
// type/title/original_title/year/director/season). Базовый (наименее
// доверенный) слой источника полей display_name; пусто, пока не выведена.
// Косметика: на пути/раскладку не влияет.
ParsedContext string `db:"parsed_context"`
State State `db:"state"`
ErrorCode sql.NullString `db:"error_code"`
ErrorMsg sql.NullString `db:"error_msg"`
// SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в
// qBittorrent (дебаунс пропажи источника, см. state-reconciliation).
SourceMissCount int `db:"source_miss_count"`
@@ -806,6 +811,19 @@ WHERE id = ?`, name, FormatTime(Now()), id); err != nil {
return nil
}
// SetParsedContext сохраняет извлечённую из контекста структуру имени (JSON) —
// базовый слой источника display_name (см. capability ingest). Косметика: FSM не
// двигает, на пути/раскладку не влияет. Пустой json допустим (нечего сохранять).
func (s *Store) SetParsedContext(ctx context.Context, id, jsonStr string) error {
if _, err := s.DB.ExecContext(ctx, `
UPDATE download
SET parsed_context = ?, updated_at = ?
WHERE id = ?`, jsonStr, FormatTime(Now()), id); err != nil {
return fmt.Errorf("set parsed context %s: %w", id, err)
}
return nil
}
// setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу.
@@ -0,0 +1,10 @@
-- +goose Up
-- Извлечённая на шаге добавления структура имени (naming.extracted, JSON:
-- type/title/original_title/year/director/season) — базовый (наименее доверенный)
-- слой источника полей отображаемого имени. Сохраняется, чтобы поля из контекста
-- (в частности режиссёр) не терялись и переиспользовались при обновлении
-- display_name без повторного вызова LLM. Косметика: на пути/раскладку не влияет.
ALTER TABLE download ADD COLUMN parsed_context TEXT NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE download DROP COLUMN parsed_context;
+20 -3
View File
@@ -2,10 +2,12 @@ package worker
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/naming"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store"
)
@@ -14,18 +16,19 @@ import (
// момент (медленного) вывода имени, симулируя параллельную отмену.
type fakeNamer struct {
name string
fields naming.Fields // извлечённая структура (ok=true, если Title непуст)
gotContext string
calls int
onCall func()
}
func (f *fakeNamer) DeriveName(_ context.Context, contextText, _ string) string {
func (f *fakeNamer) Derive(_ context.Context, contextText, _ string) (string, naming.Fields, bool) {
f.calls++
f.gotContext = contextText
if f.onCall != nil {
f.onCall()
}
return f.name
return f.name, f.fields, f.fields.Title != ""
}
func catchedStore(id, infohash, createdAt, ctxText string) *fakeStore {
@@ -53,7 +56,10 @@ func TestProcessCatchedAddsToQbit(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "Дюна 2")
qb := &fakeQbt{}
w := newTestWorker(st, qb)
nm := &fakeNamer{name: "Дюна: Часть вторая (2024)"}
nm := &fakeNamer{
name: "Дюна: Часть вторая (2024)",
fields: naming.Fields{Type: "movie", Title: "Дюна: Часть вторая", Director: "Дени Вильнёв", Year: 2024},
}
w.SetNamer(nm)
w.processCatched(context.Background())
@@ -78,6 +84,17 @@ func TestProcessCatchedAddsToQbit(t *testing.T) {
if d.DisplayName != "Дюна: Часть вторая (2024)" {
t.Errorf("display_name = %q", d.DisplayName)
}
// Извлечённая структура сохранена как parsed_context (базовый слой полей имени).
if d.ParsedContext == "" {
t.Error("parsed_context пуст, ожидалась сохранённая структура")
}
var pf naming.Fields
if err := json.Unmarshal([]byte(d.ParsedContext), &pf); err != nil {
t.Fatalf("parsed_context не JSON: %v", err)
}
if pf.Director != "Дени Вильнёв" {
t.Errorf("parsed_context.director = %q, want «Дени Вильнёв»", pf.Director)
}
}
// Торрент пойманной загрузки уже присутствует в qBittorrent (добавлен ранее
+93
View File
@@ -0,0 +1,93 @@
package worker
import (
"encoding/json"
"testing"
"git.vakhrushev.me/av/jellybit/internal/naming"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
)
func parsedContext(t *testing.T, f naming.Fields) string {
t.Helper()
b, err := json.Marshal(f)
if err != nil {
t.Fatalf("marshal fields: %v", err)
}
return string(b)
}
func episode(season int) recognize.PlanFile {
s := season
return recognize.PlanFile{Role: recognize.RoleEpisode, Season: &s}
}
// effectiveDisplayName: слоистое разрешение полей ярлыка (план → контекст) и
// сводка сезонов из плана/контекста.
func TestEffectiveDisplayName(t *testing.T) {
cases := []struct {
name string
parsed naming.Fields
plan recognize.Plan
want string
}{
{
name: "матч даёт режиссёра и год",
plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Director: "Дени Вильнёв", Year: 2024},
want: "Дюна (Дени Вильнёв, 2024)",
},
{
name: "режиссёр из контекста переживает распознавание без матча",
parsed: naming.Fields{Type: "movie", Title: "Дюна", Director: "Дени Вильнёв", Year: 2021},
plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Year: 2024}, // без director
want: "Дюна (Дени Вильнёв, 2024)", // год из плана, режиссёр из контекста
},
{
name: "режиссёр матча бьёт контекстного",
parsed: naming.Fields{Type: "movie", Title: "Дюна", Director: "Кто-то из контекста"},
plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Director: "Дени Вильнёв", Year: 2024},
want: "Дюна (Дени Вильнёв, 2024)",
},
{
name: "год из контекста, когда плана нет",
parsed: naming.Fields{Type: "movie", Title: "Брат", Year: 1997},
plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Брат"}, // year 0
want: "Брат (1997)",
},
{
name: "сериал: сводка сезонов из плана",
plan: recognize.Plan{Type: recognize.MediaSeries, Title: "Фарго",
Files: []recognize.PlanFile{episode(1), episode(2), episode(3)}},
want: "Фарго. Сезоны 13",
},
{
name: "сериал: сезон из контекста, когда в плане нет эпизодов",
parsed: naming.Fields{Type: "series", Title: "Сёгун", Season: ptrInt(2)},
plan: recognize.Plan{Type: recognize.MediaSeries, Title: "Сёгун", Year: 2024},
want: "Сёгун (2024). Сезон 2",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
d := store.Download{}
if c.parsed.Title != "" {
d.ParsedContext = parsedContext(t, c.parsed)
}
if got := effectiveDisplayName(d, c.plan); got != c.want {
t.Errorf("effectiveDisplayName = %q, want %q", got, c.want)
}
})
}
}
// Битый parsed_context не валит рендер (best-effort): используется только план.
func TestEffectiveDisplayNameBrokenParsedContext(t *testing.T) {
d := store.Download{ParsedContext: "{не json"}
plan := recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Year: 2024}
if got := effectiveDisplayName(d, plan); got != "Дюна (2024)" {
t.Errorf("effectiveDisplayName = %q, want «Дюна (2024)»", got)
}
}
func ptrInt(v int) *int { return &v }
+80 -10
View File
@@ -29,6 +29,7 @@ const (
ovrProviderID = "provider_id" // id в выбранной базе
ovrTitle = "title" // запиненное каноническое название
ovrYear = "year" // запиненный год
ovrDirector = "director" // режиссёр выбранного источника (credits метабазы)
ovrForceReview = "force_review" // ручная перепривязка: не авто-раскладывать
)
@@ -785,7 +786,15 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
if cand.Year.Valid {
year = int(cand.Year.Int64)
}
for field, value := range sourcePins(cand.Provider, cand.ProviderID, title, year) {
// Режиссёр выбранного источника из метабазы (credits) — best-effort: пустой
// (нет провайдера/не умеет/ошибка) очищает возможный унаследованный пин; тогда
// в ярлыке режиссёр берётся из распознавания/авто-матча (plan.Director), а если
// и там пусто — из сохранённого контекста (нижний слой).
director := ""
if w.recognizer != nil {
director = w.recognizer.Director(ctx, candMediaType(rec), cand.Provider, cand.ProviderID)
}
for field, value := range sourcePins(cand.Provider, cand.ProviderID, title, year, director) {
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
@@ -825,9 +834,22 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
if err != nil {
return err
}
// Режиссёр вручную заданного источника из метабазы (credits) — best-effort
// косметика: сбой чтения рекогниции не валит смену источника (тип по умолчанию
// movie, как трактует candMediaType(nil)).
director := ""
if w.recognizer != nil {
rec, rerr := w.store.GetCurrentRecognition(ctx, id)
if rerr != nil {
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).
Warn("set provider: recognition lookup for director failed", "error", rerr)
rec = nil
}
director = w.recognizer.Director(ctx, candMediaType(rec), provider, providerID)
}
// Полный набор пинов: id задан вручную, название/год берём из плана
// (очищаем возможный унаследованный пин прежнего источника).
for field, value := range sourcePins(provider, providerID, "", 0) {
for field, value := range sourcePins(provider, providerID, "", 0, director) {
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("set provider: %w", err)
}
@@ -848,7 +870,7 @@ func (w *Worker) ClearProvider(ctx context.Context, id string) (err error) {
if err != nil {
return err
}
for field, value := range sourcePins("none", "", "", 0) {
for field, value := range sourcePins("none", "", "", 0, "") {
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("clear provider: %w", err)
}
@@ -862,7 +884,7 @@ func (w *Worker) ClearProvider(ctx context.Context, id string) (err error) {
// пустая строка трактуется как «нет override» → берётся значение плана. Так
// выбор любого источника даёт детерминированный эффективный план, а превью
// совпадает с применением. Используется и в коммите (SetOverride), и в превью.
func sourcePins(provider, providerID, title string, year int) map[string]string {
func sourcePins(provider, providerID, title string, year int, director string) map[string]string {
yr := ""
if year > 0 {
yr = strconv.Itoa(year)
@@ -872,9 +894,19 @@ func sourcePins(provider, providerID, title string, year int) map[string]string
ovrProviderID: providerID,
ovrTitle: title,
ovrYear: yr,
ovrDirector: director,
}
}
// candMediaType — тип из текущей рекогниции (для выборки режиссёра нужным
// эндпоинтом провайдера). Неизвестный/пустой → movie (дефолт схемы).
func candMediaType(rec *store.Recognition) recognize.MediaType {
if rec != nil && rec.MediaType.String == string(recognize.MediaSeries) {
return recognize.MediaSeries
}
return recognize.MediaMovie
}
// --- Данные для экрана ревью ---
// ReviewData — всё, что нужно транспорту для отрисовки ревью.
@@ -1011,7 +1043,10 @@ func (w *Worker) buildSources(ctx context.Context, downloadID string, rawPlan re
// overrides, считает эффективный план и предпросмотр путей — эфемерно, без
// записи. Гарантия preview == apply: тот же набор пинов запишет выбор.
func (w *Worker) sourceOption(ctx context.Context, downloadID string, kind SourceKind, rawPlan recognize.Plan, base map[string]string, candID, provider, providerID, url, title string, year int, active bool) SourceOption {
eff := applyOverrides(rawPlan, mergeSourceOverrides(base, sourcePins(provider, providerID, title, year)))
// Режиссёр в эфемерном превью не тянем (пусто): он не влияет на пути раскладки,
// а per-candidate выборка credits дорога. Режиссёр появляется в инфо-панели
// после закрепления выбора (ovrDirector персистится в chooseCandidateLocked).
eff := applyOverrides(rawPlan, mergeSourceOverrides(base, sourcePins(provider, providerID, title, year, "")))
opt := SourceOption{
Kind: kind,
CandidateID: candID,
@@ -1035,13 +1070,14 @@ func (w *Worker) sourceOption(ctx context.Context, downloadID string, kind Sourc
return opt
}
// mergeSourceOverrides накладывает пины источника (provider/id/title/year) на
// неисточниковые overrides (media_type, ignored_files, force_review, ...).
// mergeSourceOverrides накладывает пины источника (provider/id/title/year/
// director) на неисточниковые overrides (media_type, ignored_files, force_review,
// ...).
func mergeSourceOverrides(base, pins map[string]string) map[string]string {
m := make(map[string]string, len(base)+len(pins))
for k, v := range base {
switch k {
case ovrProvider, ovrProviderID, ovrTitle, ovrYear:
case ovrProvider, ovrProviderID, ovrTitle, ovrYear, ovrDirector:
continue
default:
m[k] = v
@@ -1072,6 +1108,37 @@ func (w *Worker) effectivePlan(ctx context.Context, id string) (plan recognize.P
return applyOverrides(plan, overrides), prov, pid, nil
}
// effectiveDisplayName собирает полный ярлык display_name из эффективных полей:
// плана (override → распознавание+матч, уже свёрнуто effectivePlan) с fallback на
// сохранённый контекст (parsed_context) для полей, которых план не дал (например
// режиссёр из контекста при распознавании без матча). Сводка сезонов —
// recognize.SeasonSummary(plan) с fallback на контекстный скаляр. Единый формат с
// шагом добавления (общий naming.Label). parsed_context недоверен: битый JSON →
// пустой слой (best-effort), очистка полей — на рендере.
func effectiveDisplayName(d store.Download, plan recognize.Plan) string {
var ctxf naming.Fields
if s := strings.TrimSpace(d.ParsedContext); s != "" {
_ = json.Unmarshal([]byte(s), &ctxf)
}
title := plan.Title
if title == "" {
title = ctxf.Title
}
director := plan.Director
if director == "" {
director = ctxf.Director
}
year := plan.Year
if year == 0 {
year = ctxf.Year
}
season := recognize.SeasonSummary(plan)
if season == "" && plan.Type == recognize.MediaSeries {
season = ctxf.SeasonLabel()
}
return naming.Label(title, director, year, season)
}
// RefreshDisplayName — внешняя точка входа обновления отображаемого имени
// (ручная кнопка): берёт w.mu и делегирует refreshDisplayNameLocked.
func (w *Worker) RefreshDisplayName(ctx context.Context, id string) (err error) {
@@ -1107,9 +1174,9 @@ func (w *Worker) refreshDisplayNameLocked(ctx context.Context, id string) error
if err != nil {
return fmt.Errorf("refresh display name: %w", err)
}
name := naming.FormatTitleYear(plan.Title, plan.Year)
name := effectiveDisplayName(*d, plan)
if name == "" {
return nil // пустое распознанное название — no-op
return nil // пустое эффективное название — no-op
}
if err := w.store.SetDisplayName(cctx, id, name); err != nil {
return fmt.Errorf("refresh display name: %w", err)
@@ -1201,6 +1268,9 @@ func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.
plan.Year = year
}
}
if d := overrides[ovrDirector]; d != "" {
plan.Director = d
}
ignored := parseIgnored(overrides[ovrIgnoredFiles])
if len(ignored) > 0 {
for i := range plan.Files {
+37 -4
View File
@@ -470,6 +470,13 @@ func (m *memStore) SetDisplayName(_ context.Context, id, name string) error {
return nil
}
func (m *memStore) SetParsedContext(_ context.Context, id, jsonStr string) error {
if d, ok := m.downloads[id]; ok {
d.ParsedContext = jsonStr
}
return nil
}
func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error {
if d, ok := m.downloads[id]; ok {
d.SourceMissCount = n
@@ -656,10 +663,11 @@ func jsonMarshal(v any) (string, error) {
// fakeRecognizer возвращает заданный результат; onCall — побочный эффект для
// симуляции гонок (напр. отмена во время вызова LLM).
type fakeRecognizer struct {
result recognize.Result
err error
onCall func()
calls int
result recognize.Result
err error
onCall func()
calls int
director string // возвращается Director (режиссёр выбранного источника)
}
func (f *fakeRecognizer) Recognize(_ context.Context, _ recognize.Input) (recognize.Result, error) {
@@ -670,6 +678,10 @@ func (f *fakeRecognizer) Recognize(_ context.Context, _ recognize.Input) (recogn
return f.result, f.err
}
func (f *fakeRecognizer) Director(_ context.Context, _ recognize.MediaType, _, _ string) string {
return f.director
}
func testWorkerWith(st Store, qb QBittorrent, rec Recognizer, lay Layouter) *Worker {
w := New(st, qb, rec, lay, Config{Category: "jellybit"},
slog.New(slog.NewTextHandler(io.Discard, nil)))
@@ -1677,6 +1689,27 @@ func TestChooseCandidate_RefreshesDisplayName(t *testing.T) {
}
}
// A2: выбор кандидата тянет режиссёра из метабазы (recognizer.Director), пиннит
// его как ovrDirector и переливает полный ярлык «Название (режиссёр, год)».
func TestChooseCandidate_DirectorFromMetabase(t *testing.T) {
w, st, _ := refreshSetup(t, store.MetadataCandidate{
Provider: "tmdb", ProviderID: "693134",
Title: store.NullString("Дюна"), Year: sql.NullInt64{Int64: 2024, Valid: true},
})
w.recognizer = &fakeRecognizer{director: "Дени Вильнёв"}
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
if ov := st.overrides["1"]; ov[ovrDirector] != "Дени Вильнёв" {
t.Errorf("ovrDirector = %q, want «Дени Вильнёв»", ov[ovrDirector])
}
const want = "Дюна (Дени Вильнёв, 2024)"
if got := st.downloads["1"].DisplayName; got != want {
t.Errorf("display_name = %q, want %q", got, want)
}
}
func TestChooseCandidate_RefreshDisplayName_QbitErrorTolerated(t *testing.T) {
w, st, qb := refreshSetup(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "343558",
+24 -3
View File
@@ -13,6 +13,7 @@ package worker
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -25,6 +26,7 @@ import (
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/logctx"
"git.vakhrushev.me/av/jellybit/internal/magnet"
"git.vakhrushev.me/av/jellybit/internal/naming"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
@@ -52,6 +54,7 @@ type Store interface {
// SetDisplayName обновляет отображаемое имя постфактум (перелив канонического
// имени после распознавания) — без гарда состояния, FSM не двигает.
SetDisplayName(ctx context.Context, id, name string) error
SetParsedContext(ctx context.Context, id, jsonStr string) error
SetSourceMissCount(ctx context.Context, id string, n int) error
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
// SetRetriedAt проставляет время ручного retry — сброс базиса отсчёта
@@ -104,12 +107,18 @@ type QBittorrent interface {
// Recognizer — распознаватель (recognize.Recognizer).
type Recognizer interface {
Recognize(ctx context.Context, in recognize.Input) (recognize.Result, error)
// Director тянет режиссёра выбранного источника по (provider, id) из
// метабазы (credits) — для ручного выбора кандидата в ревью. Best-effort:
// нет провайдера/не умеет/ошибка → пустая строка.
Director(ctx context.Context, mt recognize.MediaType, provider, providerID string) string
}
// Namer выводит человекочитаемое отображаемое имя из контекста (naming.Namer).
// Пустой результат → rename в qBittorrent не задаём. nil → имя не выводим.
// Derive возвращает имя (пустое → rename в qBittorrent не задаём) и извлечённую
// структуру (её JSON сохраняется как parsed_context — базовый слой полей имени).
// nil-namer → имя не выводим.
type Namer interface {
DeriveName(ctx context.Context, contextText, hint string) string
Derive(ctx context.Context, contextText, hint string) (name string, fields naming.Fields, ok bool)
}
// Layouter — раскладчик хардлинками (layout.Layouter).
@@ -452,7 +461,19 @@ func (w *Worker) processCatched(ctx context.Context) {
}
var rename string
if w.namer != nil {
rename = w.namer.DeriveName(cctx, cur.Context, hint)
name, fields, extracted := w.namer.Derive(cctx, cur.Context, hint)
rename = name
// Сохраняем извлечённую структуру как базовый слой полей имени
// (parsed_context) — режиссёр/год из контекста не теряются и
// переиспользуются при обновлении display_name. Best-effort: сбой не
// валит добавление (косметика). Пустая структура → нечего сохранять.
if extracted {
if blob, mErr := json.Marshal(fields); mErr == nil {
if sErr := w.store.SetParsedContext(cctx, cur.ID, string(blob)); sErr != nil {
logctx.From(cctx).Warn("catched save parsed context failed", "error", sErr)
}
}
}
}
addReq.Rename = rename
addErr := w.qbt.Add(cctx, addReq)
+9
View File
@@ -200,6 +200,15 @@ func (f *fakeStore) SetDisplayName(_ context.Context, id, name string) error {
return nil
}
func (f *fakeStore) SetParsedContext(_ context.Context, id, jsonStr string) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %s not found", id)
}
d.ParsedContext = jsonStr
return nil
}
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
d, ok := f.downloads[id]
if !ok {