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:
@@ -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 (добавлен ранее
|
||||
|
||||
@@ -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: "Фарго. Сезоны 1–3",
|
||||
},
|
||||
{
|
||||
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
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user