diff --git a/docs/backlog/README.md b/docs/backlog/README.md
index c603012..c1c9885 100644
--- a/docs/backlog/README.md
+++ b/docs/backlog/README.md
@@ -31,7 +31,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
- [Аниме с абсолютной нумерацией](anime-absolyutnaya-numeraciya.md) — Релизы аниме часто нумеруют серии сквозным числом (#137) без сезонов, а Jellyfin ждёт…
- [Бэкап SQLite](backup-sqlite.md) — architecture
- [Сигнал Jellyfin после отката и удаления файлов](jellyfin-skan-posle-udaleniya.md) — Скан шлётся только на done; после reverted/deleted Jellyfin держит битые записи. Клиент и гейт готовы, но скана нет в openspec-спеках
-- [Режиссёр в блоке «Распознано как» на странице загрузки](rezhisser-v-kartochke-zagruzki.md) — На /download/{id} режиссёр всегда прочерк (поля нет в шаблоне); экран ревью его уже выводит
- [Глубокий healthcheck и статус зависимостей](healthcheck-zavisimosti.md) — /healthz проверяет только сам сервис
- [НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)](masshtab-100-zagruzok.md) — Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг)
- [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать…
diff --git a/docs/backlog/rezhisser-v-kartochke-zagruzki.md b/docs/backlog/rezhisser-v-kartochke-zagruzki.md
deleted file mode 100644
index db9af55..0000000
--- a/docs/backlog/rezhisser-v-kartochke-zagruzki.md
+++ /dev/null
@@ -1,50 +0,0 @@
-# Режиссёр в блоке «Распознано как» на странице загрузки
-
-**Приоритет:** средний
-
-На странице `/download/{id}` в блоке «Распознано как» режиссёр всегда показан
-прочерком — поля просто нет:
-`web/templates/partials/download_main.html:40` содержит захардкоженное
-`
Режиссёр—`.
-
-Экран ревью режиссёра уже выводит (`internal/httpapi/review.go:49/122`,
-`review_source_block.html:59`) — это сделано в change про слоистое разрешение
-полей display_name; страницу загрузки он не тронул.
-
-Данные для вывода есть: `Plan.Director` (`internal/recognize/recognize.go:91`)
-персистится в JSON-колонке `recognition.plan`, а страница загрузки уже получает
-эффективный план через `Reviewer.ReviewData(...)`
-(`internal/httpapi/download.go:89`) — `rd.Plan` уже с наложенными override
-(`internal/worker/review.go:987`, `:1271`).
-
-## Развилка (решить до кода)
-
-`rd.Plan.Director` — это слой «override → распознавание+матч», **без слоя
-контекста**. Полное слоистое разрешение живёт в неэкспортируемом
-`effectiveDisplayName(d, plan)` (`internal/worker/review.go:1118`, fallback
-`plan.Director → ctxf.Director` на `:1127-1130`) и отдаёт готовую строку, а не
-поля.
-
-- **A (минимум, ~3 строки):** поле `Director` в `downloadDetailView`
- (`internal/httpapi/download.go:37`) + присвоение `view.Director =
- rd.Plan.Director` рядом с `:143`. Но при распознавании без матча режиссёр из
- контекста не покажется, хотя в заголовке страницы (display name) он уже есть →
- видимая нестыковка «в заголовке есть, в поле прочерк».
-- **B (правильный):** вынести слоистое разрешение в экспортируемое
- `naming.Fields`/`EffectiveFields(d, plan)`, переиспользовать в
- `effectiveDisplayName` и в **обоих** билдерах вью. Чинит заодно ту же дыру в
- `internal/httpapi/review.go:122`.
-
-Рекомендация — B: A оставляет ровно тот баг, который заводили.
-
-## Спеки
-
-- `openspec/specs/web-ui/spec.md` — про блок «Распознано как» на `/download/{id}`
- режиссёра нет вообще → нужна дельта.
-- `openspec/specs/review/spec.md:255-289` — сценарий «Режиссёр показан, когда
- доступен» ограничен экраном ревью и формулировкой «из подтверждённого
- матча/кандидата»; вариант B потребует правки формулировки.
-- `openspec/specs/recognition/spec.md:62-69` — режиссёр опционален и best-effort,
- пустое значение штатно.
-
-Связано: [[ubiquitous-language-slovar]].
diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go
index ba3c379..399f4a2 100644
--- a/internal/httpapi/download.go
+++ b/internal/httpapi/download.go
@@ -6,6 +6,7 @@ import (
"strconv"
"time"
+ "git.vakhrushev.me/av/jellybit/internal/naming"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
@@ -38,6 +39,7 @@ type downloadDetailView struct {
OriginalTitle string
Season string // сводка сезонов для сериала (пусто для фильма)
Year int
+ Director string // режиссёр эффективного источника (пусто — неизвестен)
Provider string
ProviderID string
MatchURL string // ссылка на запись метабазы (пусто — показываем текстом)
@@ -145,6 +147,10 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
view.Season = recognize.SeasonSummary(rd.Plan)
}
view.Year = rd.Plan.Year
+ // Режиссёр — из того же слоистого разрешения, что и display_name в шапке
+ // (override → распознавание+матч → контекст), чтобы поле не расходилось с
+ // заголовком; пустой во всех слоях → прочерк в шаблоне.
+ view.Director = naming.EffectiveFields(d.ParsedContext, rd.Plan).Director
// Ручное обновление имени доступно, когда есть распознанное название,
// которое можно перелить (иначе FormatTitleYear даст пусто → no-op).
view.Nameable = rd.Plan.Title != ""
diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go
index 0dd7fed..e64cf15 100644
--- a/internal/httpapi/render_test.go
+++ b/internal/httpapi/render_test.go
@@ -9,6 +9,7 @@ import (
"strings"
"testing"
+ "git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
@@ -119,6 +120,55 @@ func TestRouterRendersPages(t *testing.T) {
}
}
+// TestDownloadPageShowsDirector — блок «Распознано как» на /download/{id}
+// выводит режиссёра слоистым разрешением: из плана (матч) и из сохранённого
+// контекста при распознавании без матча (ранее поле было захардкожено прочерком).
+func TestDownloadPageShowsDirector(t *testing.T) {
+ base := func(plan recognize.Plan, parsedContext string) *worker.ReviewData {
+ return &worker.ReviewData{
+ Download: store.Download{
+ ID: testULID, SourceRef: "Dune", State: store.StateReview,
+ ParsedContext: parsedContext,
+ },
+ Recognition: &store.Recognition{ID: "1", DownloadID: testULID, IsCurrent: true},
+ Plan: plan,
+ }
+ }
+ moviePlan := func(director string) recognize.Plan {
+ return recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Year: 2024, Director: director,
+ Files: []recognize.PlanFile{{Src: "dune.mkv", Role: recognize.RoleMain}}}
+ }
+
+ cases := []struct {
+ name string
+ rd *worker.ReviewData
+ wantInBody string
+ }{
+ {
+ name: "режиссёр из плана (матч)",
+ rd: base(moviePlan("Дени Вильнёв"), ""),
+ wantInBody: "Дени Вильнёв",
+ },
+ {
+ name: "режиссёр из контекста при распознавании без матча",
+ rd: base(moviePlan(""), `{"type":"movie","title":"Дюна","director":"Дени Вильнёв"}`),
+ wantInBody: "Дени Вильнёв",
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ h := testRouter(t, stubReader{one: &c.rd.Download}, stubReviewer{data: c.rd})
+ rr := get(t, h, "/download/"+testULID)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("GET /download/{id} = %d, want 200", rr.Code)
+ }
+ if !strings.Contains(rr.Body.String(), c.wantInBody) {
+ t.Errorf("страница загрузки не содержит режиссёра %q", c.wantInBody)
+ }
+ })
+ }
+}
+
// TestStaticServed проверяет отдачу встроенной статики с кэш-заголовком.
func TestStaticServed(t *testing.T) {
h := testRouter(t, stubReader{}, stubReviewer{})
diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go
index 018a834..1f9e315 100644
--- a/internal/httpapi/review.go
+++ b/internal/httpapi/review.go
@@ -9,6 +9,7 @@ import (
"strings"
"git.vakhrushev.me/av/jellybit/internal/ident"
+ "git.vakhrushev.me/av/jellybit/internal/naming"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
@@ -119,7 +120,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
+ // Режиссёр — из слоистого разрешения (override → распознавание+матч →
+ // контекст), а не только из плана: иначе слой контекста (режиссёр без
+ // матча) терялся бы, как на странице загрузки.
+ view.Director = naming.EffectiveFields(rd.Download.ParsedContext, rd.Plan).Director
view.Year = rd.Plan.Year
if view.IsSeries {
view.SeasonSummary = recognize.SeasonSummary(rd.Plan)
diff --git a/internal/naming/effective_test.go b/internal/naming/effective_test.go
new file mode 100644
index 0000000..d2ab17e
--- /dev/null
+++ b/internal/naming/effective_test.go
@@ -0,0 +1,112 @@
+package naming
+
+import (
+ "encoding/json"
+ "testing"
+
+ "git.vakhrushev.me/av/jellybit/internal/recognize"
+)
+
+func mustJSON(t *testing.T, f 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}
+}
+
+// EffectiveFields: слоистое разрешение полей ярлыка (план → контекст) и сводка
+// сезонов из плана/контекста. Проверяем и итоговый Label (совпадение с прежним
+// display_name), и отдельно очищенное поле Director (его печатает шаблон).
+func TestEffectiveFields(t *testing.T) {
+ cases := []struct {
+ name string
+ parsed Fields // пустой Title → parsed_context не задаётся
+ plan recognize.Plan
+ want string // ожидаемый Label()
+ wantDir string // ожидаемый Director
+ }{
+ {
+ name: "матч даёт режиссёра и год",
+ plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Director: "Дени Вильнёв", Year: 2024},
+ want: "Дюна (Дени Вильнёв, 2024)",
+ wantDir: "Дени Вильнёв",
+ },
+ {
+ name: "режиссёр из контекста переживает распознавание без матча",
+ parsed: Fields{Type: "movie", Title: "Дюна", Director: "Дени Вильнёв", Year: 2021},
+ plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Year: 2024}, // без director
+ want: "Дюна (Дени Вильнёв, 2024)", // год из плана, режиссёр из контекста
+ wantDir: "Дени Вильнёв",
+ },
+ {
+ name: "режиссёр матча бьёт контекстного",
+ parsed: Fields{Type: "movie", Title: "Дюна", Director: "Кто-то из контекста"},
+ plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Director: "Дени Вильнёв", Year: 2024},
+ want: "Дюна (Дени Вильнёв, 2024)",
+ wantDir: "Дени Вильнёв",
+ },
+ {
+ name: "год из контекста, когда плана нет",
+ parsed: Fields{Type: "movie", Title: "Брат", Year: 1997},
+ plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Брат"}, // year 0, без режиссёра
+ want: "Брат (1997)",
+ wantDir: "",
+ },
+ {
+ name: "сериал: сводка сезонов из плана",
+ plan: recognize.Plan{Type: recognize.MediaSeries, Title: "Фарго",
+ Files: []recognize.PlanFile{episode(1), episode(2), episode(3)}},
+ want: "Фарго. Сезоны 1–3",
+ wantDir: "",
+ },
+ {
+ name: "сериал: сезон из контекста, когда в плане нет эпизодов",
+ parsed: Fields{Type: "series", Title: "Сёгун", Season: ptr(2)},
+ plan: recognize.Plan{Type: recognize.MediaSeries, Title: "Сёгун", Year: 2024},
+ want: "Сёгун (2024). Сезон 2",
+ wantDir: "",
+ },
+ {
+ name: "режиссёр из управляющих символов и лишних пробелов очищается",
+ plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Director: " Дени\tВильнёв\n ", Year: 2024},
+ want: "Дюна (Дени Вильнёв, 2024)",
+ wantDir: "Дени Вильнёв",
+ },
+ {
+ name: "режиссёр только из управляющих символов даёт пустое поле и выпадает из ярлыка",
+ plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Director: "\x01\x02", Year: 2024},
+ want: "Дюна (2024)",
+ wantDir: "",
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ parsed := ""
+ if c.parsed.Title != "" {
+ parsed = mustJSON(t, c.parsed)
+ }
+ ef := EffectiveFields(parsed, c.plan)
+ if got := ef.Label(); got != c.want {
+ t.Errorf("Label() = %q, want %q", got, c.want)
+ }
+ if ef.Director != c.wantDir {
+ t.Errorf("Director = %q, want %q", ef.Director, c.wantDir)
+ }
+ })
+ }
+}
+
+// Битый parsed_context не валит разрешение (best-effort): используется только план.
+func TestEffectiveFieldsBrokenParsedContext(t *testing.T) {
+ plan := recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Year: 2024}
+ if got := EffectiveFields("{не json", plan).Label(); got != "Дюна (2024)" {
+ t.Errorf("Label() = %q, want «Дюна (2024)»", got)
+ }
+}
diff --git a/internal/naming/naming.go b/internal/naming/naming.go
index 7fde1c9..5c5ef9c 100644
--- a/internal/naming/naming.go
+++ b/internal/naming/naming.go
@@ -16,12 +16,14 @@ package naming
import (
"context"
+ "encoding/json"
"log/slog"
"strconv"
"strings"
"unicode/utf8"
"git.vakhrushev.me/av/jellybit/internal/llm"
+ "git.vakhrushev.me/av/jellybit/internal/recognize"
)
// maxNameLen — ограничение длины отображаемого имени (символов/рун).
@@ -45,6 +47,61 @@ type Fields struct {
IsRussian bool `json:"is_russian"`
}
+// LabelFields — эффективные скалярные поля отображаемого ярлыка после слоистого
+// разрешения (план → контекст). Значения уже очищены (sanitize): управляющие
+// символы вырезаны, пробелы схлопнуты — те же, что попадут внутрь Label, поэтому
+// присутствие/отсутствие поля определяется одинаково при прямом выводе (шаблон)
+// и внутри Label. Season — готовая строка-сводка сезонов (для фильма пусто).
+type LabelFields struct {
+ Title string
+ Director string
+ Year int
+ Season string
+}
+
+// Label собирает полный ярлык display_name из эффективных полей. sanitize внутри
+// идемпотентен, поэтому на уже очищенных полях результат тот же.
+func (f LabelFields) Label() string {
+ return Label(f.Title, f.Director, f.Year, f.Season)
+}
+
+// EffectiveFields разрешает поля отображаемого ярлыка слоями: план (override →
+// распознавание+матч, уже свёрнут вызывающим) с фолбэком на извлечённый из
+// контекста слой (parsedContext, схема Fields) для полей, которых план не дал
+// (например режиссёр из контекста при распознавании без матча). Сводка сезонов —
+// recognize.SeasonSummary(plan) с фолбэком на контекстный скаляр. parsedContext
+// недоверен: битый JSON → пустой слой (best-effort). Выбор слоя идёт по сырым
+// значениям (как прежде), выбранные скаляры возвращаются очищенными (sanitize),
+// чтобы прямой вывод поля во вью совпадал с тем, что даёт Label в шапке.
+func EffectiveFields(parsedContext string, plan recognize.Plan) LabelFields {
+ var ctxf Fields
+ if s := strings.TrimSpace(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 LabelFields{
+ Title: sanitize(title),
+ Director: sanitize(director),
+ Year: year,
+ Season: sanitize(season),
+ }
+}
+
// SeasonLabel — сводка сезона из контекстного скаляра: «Сезон N» для сериала с
// заданным номером, иначе пусто. Для контекста сезон скалярный (в отличие от
// плана распознавания, где он per-file и сводится recognize.SeasonSummary).
diff --git a/internal/worker/review.go b/internal/worker/review.go
index d5c2ee0..3885f81 100644
--- a/internal/worker/review.go
+++ b/internal/worker/review.go
@@ -1135,35 +1135,12 @@ 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), очистка полей — на рендере.
+// effectiveDisplayName собирает полный ярлык display_name из эффективных полей.
+// Тонкая обёртка над naming.EffectiveFields (слоистое разрешение план →
+// контекст) + Label — единый источник разрешения полей ярлыка, общий со
+// страницей загрузки и экраном ревью.
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)
+ return naming.EffectiveFields(d.ParsedContext, plan).Label()
}
// RefreshDisplayName — внешняя точка входа обновления отображаемого имени
diff --git a/openspec/changes/archive/2026-07-17-download-card-director/design.md b/openspec/changes/archive/2026-07-17-download-card-director/design.md
new file mode 100644
index 0000000..a549efe
--- /dev/null
+++ b/openspec/changes/archive/2026-07-17-download-card-director/design.md
@@ -0,0 +1,132 @@
+## Context
+
+`display_name` и поля блоков «Распознано как» / «инфо источника» — косметика
+отображения, не влияющая на пути и раскладку. Слоистое разрешение полей ярлыка
+(первый непустой слой `override` → `recognition`+матч → **контекст**) уже
+реализовано, но заперто в неэкспортируемом `worker.effectiveDisplayName`
+(`internal/worker/review.go:1145`), который:
+
+1. читает `naming.Fields` из `download.parsed_context` (нижний слой «контекст»);
+2. для каждого поля берёт первый непустой слой: `plan.Title→ctxf.Title`,
+ `plan.Director→ctxf.Director`, `plan.Year→ctxf.Year`, сводка сезонов
+ `recognize.SeasonSummary(plan)→ctxf.SeasonLabel()`;
+3. рендерит строку `naming.Label(...)`.
+
+Билдеры вью строят режиссёра **в обход** этой логики — прямо `rd.Plan.Director`
+(`internal/httpapi/review.go:122`), а на странице загрузки
+(`internal/httpapi/download.go`) поле вообще захардкожено прочерком в шаблоне.
+Итог — слой контекста теряется, и в заголовке страницы режиссёр есть, а в поле —
+прочерк.
+
+Constraints (инварианты): вывод имени НИКОГДА не валит приём (деградация к
+пустому); `parsed_context` недоверен (битый JSON → пустой слой, best-effort);
+`director` — недоверенное косметическое поле, чистится на рендере (`naming.Label`
+уже санитизирует); без сети и без изменения схемы БД.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Один источник истины для слоистого разрешения полей ярлыка — экспортируемая
+ функция в `naming`, переиспользуемая в `effectiveDisplayName` и в обоих
+ билдерах вью.
+- Режиссёр на `/download/{id}` согласован с режиссёром в заголовке страницы.
+- Поведение `display_name` не меняется (побайтно тот же результат) — существующие
+ тесты `worker`/`naming` зелёные.
+
+**Non-Goals:**
+
+- Не меняем слои Title/Year/Season в блоке «Распознано как»: они остаются из
+ плана (что распознано), как сейчас — расширение слоёв контекста на эти поля вне
+ scope (это изменило бы отображение блока). Правится только режиссёр — поле,
+ которое даже в норме приходит не из LLM, а из матча/override/контекста.
+- Не трогаем схему БД, метабазы, распознавание, раскладку.
+
+## Decisions
+
+### D1. Сигнатура: `naming.EffectiveFields(parsedContext string, plan recognize.Plan)`
+
+Функция принимает **строку `parsed_context`**, а не `store.Download`. Так `naming`
+не тянет зависимость на `store` (пакет `naming` — низкоуровневый вывод имени; уже
+знает про `parsed_context` концептуально — его схема это `naming.Fields`). Импорт
+`naming`→`recognize` добавляется (нужен `recognize.Plan` и
+`recognize.SeasonSummary`); цикла нет — `recognize` не импортирует `naming`
+(проверено).
+
+Возвращает не строку, а тип полей:
+
+```go
+// LabelFields — эффективные скалярные поля ярлыка после слоистого разрешения.
+// Значения уже очищены (sanitize): управляющие символы вырезаны, пробелы
+// схлопнуты — те же, что попадут внутрь Label. Season — готовая строка-сводка
+// сезонов (для фильма пусто).
+type LabelFields struct {
+ Title string
+ Director string
+ Year int
+ Season string
+}
+
+func (f LabelFields) Label() string { return Label(f.Title, f.Director, f.Year, f.Season) }
+
+func EffectiveFields(parsedContext string, plan recognize.Plan) LabelFields
+```
+
+`Season` — строка, а не `*int`: сводка плана многосезонна («Сезоны 1–3»,
+«Спецвыпуски»), в `*int` не выражается. Метод `Label()` даёт единый рендер, чтобы
+`effectiveDisplayName` остался тонкой обёрткой.
+
+**Санитайзинг на возврате.** Выбор слоя (первый непустой) идёт по **сырым**
+значениям (`plan.Director != ""` и т.д. — как в исходном `effectiveDisplayName`),
+но выбранное значение возвращается уже прогнанным через `naming.sanitize` (в
+пакете `naming` он доступен). Причина — билдеры вью печатают `.Director` напрямую
+(`{{.Director}}`), в обход `Label`; без очистки на возврате (а) недоверенный
+`parsed_context.director` дошёл бы до страницы без санитайзинга, (б) режиссёр из
+одних управляющих символов дал бы `{{if .Director}}` истинным в поле, тогда как
+`Label` его выбросил бы из заголовка — то самое рассогласование «в поле есть, в
+шапке нет». `sanitize` идемпотентен, поэтому повторная очистка внутри `Label`
+ничего не меняет: `Label()` даёт **побайтно** прежний `display_name` (тесты
+`worker`/`naming` зелёные).
+
+Альтернатива (вариант A из беклога — присвоить `view.Director = rd.Plan.Director`
+на странице загрузки) отклонена: оставляет ровно тот баг (нет слоя контекста →
+нестыковка с заголовком) и не чинит `review.go`.
+
+### D2. `worker.effectiveDisplayName` — тонкая обёртка
+
+```go
+func effectiveDisplayName(d store.Download, plan recognize.Plan) string {
+ return naming.EffectiveFields(d.ParsedContext, plan).Label()
+}
+```
+
+Логика перенесена дословно, поэтому результат идентичен. Обёртку и её тесты
+(`displayname_test.go`) сохраняем — они продолжают проверять инвариант «имя не
+изменилось».
+
+### D3. Билдеры вью читают `.Director`
+
+- `download.go`: новое поле `downloadDetailView.Director`; в `buildDownloadView`
+ (внутри `if rd.Recognition != nil`) —
+ `view.Director = naming.EffectiveFields(d.ParsedContext, rd.Plan).Director`.
+- `review.go`: `view.Director = naming.EffectiveFields(rd.Download.ParsedContext, rd.Plan).Director`
+ вместо `rd.Plan.Director`.
+
+Только `Director` берётся из слоистого разрешения; Title/Year/Season в блоке
+остаются как есть (см. Non-Goals).
+
+### D4. Шаблон
+
+`download_main.html:40` — прочерк заменяется на условный вывод по конвенции
+соседних полей: `{{if .Director}}{{.Director}}{{else}}—{{end}}`.
+Деградация без JS сохраняется (страница server-rendered, поле статично).
+
+## Risks / Trade-offs
+
+- Импорт `naming`→`recognize` расширяет зависимости пакета `naming`. Приемлемо:
+ цикла нет, `recognize` — доменный тип плана, а `naming` уже оперирует его
+ сводкой сезонов косвенно (через worker). Альтернатива — дублировать логику
+ разрешения в трёх местах — хуже (дрейф).
+- Незначительный: `EffectiveFields` парсит `parsed_context` на каждый рендер
+ страницы. Это дешёвый `json.Unmarshal` короткой строки, страница и так
+ server-rendered без БД на рендере блока — некритично.
diff --git a/openspec/changes/archive/2026-07-17-download-card-director/proposal.md b/openspec/changes/archive/2026-07-17-download-card-director/proposal.md
new file mode 100644
index 0000000..3fda932
--- /dev/null
+++ b/openspec/changes/archive/2026-07-17-download-card-director/proposal.md
@@ -0,0 +1,60 @@
+## Why
+
+На странице просмотра загрузки `/download/{id}` в блоке «Распознано как» поле
+«Режиссёр» всегда показано прочерком — оно захардкожено
+(`web/templates/partials/download_main.html:40`), в отличие от экрана ревью, где
+режиссёр уже выводится. Данные есть: `Plan.Director` персистится в
+`recognition.plan`, а страница получает эффективный план через
+`Reviewer.ReviewData`. Возникает видимая нестыковка: в заголовке страницы
+(`display_name`) режиссёр из контекста уже присутствует, а в поле блока — прочерк.
+
+Причина глубже одного шаблона: полное **слоистое разрешение** полей ярлыка
+(override → распознавание+матч → **контекст**) заперто в неэкспортируемом
+`worker.effectiveDisplayName`, который отдаёт готовую строку, а не поля. Поэтому
+билдеры вью на странице загрузки и на экране ревью видят только слой
+«распознавание+матч» (`rd.Plan.Director`) без нижнего слоя контекста — та же дыра
+в `internal/httpapi/review.go`.
+
+## What Changes
+
+- **Слоистое разрешение полей ярлыка выносится в экспортируемую функцию**
+ `naming.EffectiveFields(parsedContext, plan)`, возвращающую эффективные поля
+ (в т.ч. `Director`), а не готовую строку. Логика — та же, что была в
+ `effectiveDisplayName`: первый непустой слой `override`→`recognition`(+матч),
+ уже свёрнутый в план, с фолбэком на извлечённый из контекста слой
+ (`parsed_context`).
+- **`worker.effectiveDisplayName` переиспользует новую функцию** (тонкая обёртка
+ `naming.EffectiveFields(...).Label()`) — поведение `display_name` не меняется,
+ существующие тесты остаются зелёными.
+- **Страница загрузки** выводит режиссёра из слоистого разрешения — согласованно
+ с режиссёром в заголовке (`display_name`); при отсутствии во всех слоях —
+ прочерк.
+- **Экран ревью** берёт режиссёра из той же функции — закрывается та же дыра
+ (ранее только `rd.Plan.Director`, без слоя контекста).
+
+Всё перечисленное — косметика отображения: не влияет на пути файлов,
+распознавание или раскладку.
+
+## Capabilities
+
+### New Capabilities
+
+(нет — правка отображения в существующих capability)
+
+### Modified Capabilities
+
+- `web-ui`: блок «Распознано как» на `/download/{id}` показывает режиссёра
+ эффективного источника (слоистое разрешение), согласованно с заголовком.
+- `review`: инфо-часть выбранного источника показывает режиссёра из слоистого
+ разрешения (с фолбэком на сохранённый контекст), а не только из плана.
+
+## Impact
+
+- Код: `internal/naming` (экспорт `EffectiveFields` + тип полей с методом
+ `Label`), `internal/worker` (`effectiveDisplayName` делегирует в naming),
+ `internal/httpapi` (`download.go`, `review.go` — режиссёр из слоистого
+ разрешения), `web/templates/partials/download_main.html` (поле «Режиссёр»
+ вместо прочерка).
+- БД: изменений схемы нет.
+- Внешние вызовы: нет.
+- Беклог: закрывается `docs/backlog/rezhisser-v-kartochke-zagruzki.md`.
diff --git a/openspec/changes/archive/2026-07-17-download-card-director/specs/review/spec.md b/openspec/changes/archive/2026-07-17-download-card-director/specs/review/spec.md
new file mode 100644
index 0000000..7847600
--- /dev/null
+++ b/openspec/changes/archive/2026-07-17-download-card-director/specs/review/spec.md
@@ -0,0 +1,49 @@
+# review Specification
+
+## MODIFIED Requirements
+
+### Requirement: Инфо и предпросмотр выбранного источника
+
+В едином блоке выбора источника экран ревью SHALL показывать для **выбранного
+(активного)** источника две части: **инфо** — тип (read-only, movie/series),
+название, оригинальное название, год, режиссёра эффективного источника,
+разрешённого слоями (`override`/подтверждённый матч+кандидат → сохранённый при
+приёме контекст раздачи, `parsed_context`; когда режиссёр недоступен ни в одном
+слое — пусто/прочерк, не ломая вёрстку), для сериала — сводку сезонов (один
+сезон, диапазон/список для многосезонного пака или «Спецвыпуски»); и
+**предпросмотр раскладки** — целевые пути хардлинков этого источника. Обе части
+SHALL относиться именно к активному источнику и SHALL обновляться при смене
+выбора. Отрисовка блока (показ инфо и предпросмотра) MUST NOT создавать
+хардлинки: раскладка создаётся только явным действием «Применить». Совпадение
+целевых путей предпросмотра с результатом применения регулируется требованием
+«Превью раскладки через единую логику именования» (`web-ui`).
+
+#### Scenario: Инфо и предпросмотр относятся к активному источнику
+
+- **GIVEN** в списке активен кандидат метабазы
+- **WHEN** пользователь смотрит инфо-часть и предпросмотр раскладки
+- **THEN** показаны тип, название, ориг. название, год (и сводка сезонов для
+ сериала) именно этого источника и предпросмотр его целевых путей
+
+#### Scenario: Просмотр блока не создаёт раскладку
+
+- **GIVEN** экран ревью с показанным блоком выбора источника
+- **WHEN** пользователь только просматривает инфо и предпросмотр, не нажимая
+ «Применить»
+- **THEN** хардлинки не создаются, файлы под `paths.movies`/`series` не
+ меняются
+
+#### Scenario: Режиссёр показан, когда доступен
+
+- **GIVEN** активный источник — подтверждённый матч, несущий режиссёра
+- **WHEN** отображается инфо-часть выбранного источника
+- **THEN** в ней показан режиссёр этого источника
+- **AND** при отсутствии режиссёра во всех слоях место остаётся пустым (или
+ прочерком), не ломая вёрстку
+
+#### Scenario: Режиссёр берётся из контекста, когда матч его не даёт
+
+- **GIVEN** активный источник без режиссёра в плане, но с режиссёром в
+ сохранённом контексте (`parsed_context`)
+- **WHEN** отображается инфо-часть выбранного источника
+- **THEN** в ней показан режиссёр из контекста (нижний слой разрешения)
diff --git a/openspec/changes/archive/2026-07-17-download-card-director/specs/web-ui/spec.md b/openspec/changes/archive/2026-07-17-download-card-director/specs/web-ui/spec.md
new file mode 100644
index 0000000..bd672ff
--- /dev/null
+++ b/openspec/changes/archive/2026-07-17-download-card-director/specs/web-ui/spec.md
@@ -0,0 +1,40 @@
+# web-ui Specification
+
+## ADDED Requirements
+
+### Requirement: Режиссёр в блоке распознавания страницы загрузки
+
+Страница просмотра `/download/{id}` в блоке «Распознано как» SHALL показывать
+режиссёра эффективного источника, разрешённого теми же слоями, что и
+отображаемое имя раздачи (`display_name`): первый непустой слой `override` →
+`recognition`+матч → сохранённый при приёме контекст (`parsed_context`).
+Разрешение режиссёра для поля блока и для отображаемого имени SHALL идти **единой
+логикой** (общий источник разрешения), а не расходящимися путями — прежняя
+захардкоженная в поле заглушка-прочерк при непустом режиссёре в заголовке
+устраняется. Показанное значение SHALL проходить ту же очистку (санитайзинг
+управляющих символов/пробелов), что и режиссёр внутри отображаемого имени, чтобы
+присутствие/отсутствие режиссёра в поле и в заголовке определялось одинаково.
+Когда режиссёр недоступен ни в одном слое, поле SHALL показывать прочерк, не
+ломая вёрстку.
+
+#### Scenario: Режиссёр из распознавания показан в блоке
+
+- **GIVEN** загрузка, чей эффективный план несёт режиссёра (из матча метабазы или
+ закреплённого источника)
+- **WHEN** клиент открывает `GET /download/{id}`
+- **THEN** в блоке «Распознано как» в поле «Режиссёр» показан этот режиссёр
+
+#### Scenario: Режиссёр из контекста при распознавании без матча
+
+- **GIVEN** загрузка без режиссёра в плане, но с режиссёром в сохранённом
+ контексте (`parsed_context`)
+- **WHEN** клиент открывает `GET /download/{id}`
+- **THEN** в поле «Режиссёр» показан режиссёр из контекста
+- **AND** он разрешён тем же нижним слоем контекста, что и режиссёр в
+ отображаемом имени раздачи (единая логика, не расходящиеся пути)
+
+#### Scenario: Режиссёр неизвестен — прочерк
+
+- **GIVEN** загрузка, для которой режиссёр не разрешается ни одним слоем
+- **WHEN** клиент открывает `GET /download/{id}`
+- **THEN** поле «Режиссёр» показывает прочерк, а вёрстка блока не ломается
diff --git a/openspec/changes/archive/2026-07-17-download-card-director/tasks.md b/openspec/changes/archive/2026-07-17-download-card-director/tasks.md
new file mode 100644
index 0000000..17f2ed2
--- /dev/null
+++ b/openspec/changes/archive/2026-07-17-download-card-director/tasks.md
@@ -0,0 +1,37 @@
+## 1. Экспортируемое слоистое разрешение (naming)
+
+- [x] 1.1 `internal/naming`: тип `LabelFields` (Title/Director/Year/Season string)
+ с методом `Label()`, зовущим существующий `Label(...)`
+- [x] 1.2 `internal/naming`: функция `EffectiveFields(parsedContext string, plan
+ recognize.Plan) LabelFields` — перенести логику слоёв из
+ `worker.effectiveDisplayName` (план → контекст; сводка сезонов плана →
+ контекстный `SeasonLabel`); выбор слоя по сырым значениям, выбранные скаляры
+ на возврате прогнать через `sanitize` (идемпотентно — `Label()` даёт прежний
+ результат); битый `parsed_context` → пустой слой
+- [x] 1.3 Юнит-тесты `naming.EffectiveFields`: режиссёр/год/сезон из плана;
+ фолбэк на контекст; матч бьёт контекст; битый `parsed_context`; пустые
+ значения; режиссёр из управляющих символов/лишних пробелов → `.Director`
+ очищен и `.Label()` совпадает с прежним `display_name`
+
+## 2. Переиспользование (worker)
+
+- [x] 2.1 `worker.effectiveDisplayName` → тонкая обёртка
+ `naming.EffectiveFields(d.ParsedContext, plan).Label()`; поведение и тесты
+ `displayname_test.go` не меняются
+
+## 3. Вывод режиссёра в вью (httpapi)
+
+- [x] 3.1 `download.go`: поле `downloadDetailView.Director`; в `buildDownloadView`
+ заполнить из `naming.EffectiveFields(d.ParsedContext, rd.Plan).Director`
+- [x] 3.2 `review.go`: `view.Director` из `naming.EffectiveFields(rd.Download.
+ ParsedContext, rd.Plan).Director` вместо `rd.Plan.Director`
+- [x] 3.3 `web/templates/partials/download_main.html`: поле «Режиссёр» —
+ `{{if .Director}}…{{else}}—{{end}}` вместо прочерка
+
+## 4. Проверки и закрытие
+
+- [x] 4.1 (опц.) httpapi-тест: режиссёр присутствует во вью страницы загрузки,
+ если инфраструктура тестов позволяет
+- [x] 4.2 `task test` и `task lint` зелёные; `openspec validate --strict`
+- [x] 4.3 Удалить `docs/backlog/rezhisser-v-kartochke-zagruzki.md` и строку в
+ индексе беклога `docs/backlog/README.md`
diff --git a/openspec/specs/review/spec.md b/openspec/specs/review/spec.md
index 634a826..0f4c009 100644
--- a/openspec/specs/review/spec.md
+++ b/openspec/specs/review/spec.md
@@ -256,15 +256,17 @@ NOT проваливать команду ревью. Это согласует
В едином блоке выбора источника экран ревью SHALL показывать для **выбранного
(активного)** источника две части: **инфо** — тип (read-only, movie/series),
-название, оригинальное название, год, режиссёра (из подтверждённого матча/
-кандидата, когда доступен; иначе пусто/прочерк, не ломая вёрстку), для сериала —
-сводку сезонов (один сезон, диапазон/список для многосезонного пака или
-«Спецвыпуски»); и **предпросмотр раскладки** — целевые пути хардлинков этого
-источника. Обе части SHALL относиться именно к активному источнику и SHALL
-обновляться при смене выбора. Отрисовка блока (показ инфо и предпросмотра) MUST
-NOT создавать хардлинки: раскладка создаётся только явным действием «Применить».
-Совпадение целевых путей предпросмотра с результатом применения регулируется
-требованием «Превью раскладки через единую логику именования» (`web-ui`).
+название, оригинальное название, год, режиссёра эффективного источника,
+разрешённого слоями (`override`/подтверждённый матч+кандидат → сохранённый при
+приёме контекст раздачи, `parsed_context`; когда режиссёр недоступен ни в одном
+слое — пусто/прочерк, не ломая вёрстку), для сериала — сводку сезонов (один
+сезон, диапазон/список для многосезонного пака или «Спецвыпуски»); и
+**предпросмотр раскладки** — целевые пути хардлинков этого источника. Обе части
+SHALL относиться именно к активному источнику и SHALL обновляться при смене
+выбора. Отрисовка блока (показ инфо и предпросмотра) MUST NOT создавать
+хардлинки: раскладка создаётся только явным действием «Применить». Совпадение
+целевых путей предпросмотра с результатом применения регулируется требованием
+«Превью раскладки через единую логику именования» (`web-ui`).
#### Scenario: Инфо и предпросмотр относятся к активному источнику
@@ -286,8 +288,15 @@ NOT создавать хардлинки: раскладка создаётся
- **GIVEN** активный источник — подтверждённый матч, несущий режиссёра
- **WHEN** отображается инфо-часть выбранного источника
- **THEN** в ней показан режиссёр этого источника
-- **AND** при отсутствии режиссёра место остаётся пустым (или прочерком), не
- ломая вёрстку
+- **AND** при отсутствии режиссёра во всех слоях место остаётся пустым (или
+ прочерком), не ломая вёрстку
+
+#### Scenario: Режиссёр берётся из контекста, когда матч его не даёт
+
+- **GIVEN** активный источник без режиссёра в плане, но с режиссёром в
+ сохранённом контексте (`parsed_context`)
+- **WHEN** отображается инфо-часть выбранного источника
+- **THEN** в ней показан режиссёр из контекста (нижний слой разрешения)
### Requirement: Разделение труда транспортов в ревью
diff --git a/openspec/specs/web-ui/spec.md b/openspec/specs/web-ui/spec.md
index 0c366e9..18a3096 100644
--- a/openspec/specs/web-ui/spec.md
+++ b/openspec/specs/web-ui/spec.md
@@ -511,7 +511,6 @@ htmx-поллингом (см. конвенцию веб-UI): по перехо
(бейдж, имя, живой прогресс)
- **AND** поллинг фазы `catched` завершается
-
### Requirement: Загрузка .torrent-файла на форме добавления
Форма добавления загрузки веб-UI SHALL позволять выбрать локальный
@@ -541,3 +540,41 @@ htmx-путь (список обновляется/происходит реди
- **GIVEN** пользователь оставил файловое поле пустым и ввёл magnet/текст
- **WHEN** форма отправлена
- **THEN** выполняется приём по тексту источника, как прежде
+
+### Requirement: Режиссёр в блоке распознавания страницы загрузки
+
+Страница просмотра `/download/{id}` в блоке «Распознано как» SHALL показывать
+режиссёра эффективного источника, разрешённого теми же слоями, что и
+отображаемое имя раздачи (`display_name`): первый непустой слой `override` →
+`recognition`+матч → сохранённый при приёме контекст (`parsed_context`).
+Разрешение режиссёра для поля блока и для отображаемого имени SHALL идти **единой
+логикой** (общий источник разрешения), а не расходящимися путями — прежняя
+захардкоженная в поле заглушка-прочерк при непустом режиссёре в заголовке
+устраняется. Показанное значение SHALL проходить ту же очистку (санитайзинг
+управляющих символов/пробелов), что и режиссёр внутри отображаемого имени, чтобы
+присутствие/отсутствие режиссёра в поле и в заголовке определялось одинаково.
+Когда режиссёр недоступен ни в одном слое, поле SHALL показывать прочерк, не
+ломая вёрстку.
+
+#### Scenario: Режиссёр из распознавания показан в блоке
+
+- **GIVEN** загрузка, чей эффективный план несёт режиссёра (из матча метабазы или
+ закреплённого источника)
+- **WHEN** клиент открывает `GET /download/{id}`
+- **THEN** в блоке «Распознано как» в поле «Режиссёр» показан этот режиссёр
+
+#### Scenario: Режиссёр из контекста при распознавании без матча
+
+- **GIVEN** загрузка без режиссёра в плане, но с режиссёром в сохранённом
+ контексте (`parsed_context`)
+- **WHEN** клиент открывает `GET /download/{id}`
+- **THEN** в поле «Режиссёр» показан режиссёр из контекста
+- **AND** он разрешён тем же нижним слоем контекста, что и режиссёр в
+ отображаемом имени раздачи (единая логика, не расходящиеся пути)
+
+#### Scenario: Режиссёр неизвестен — прочерк
+
+- **GIVEN** загрузка, для которой режиссёр не разрешается ни одним слоем
+- **WHEN** клиент открывает `GET /download/{id}`
+- **THEN** поле «Режиссёр» показывает прочерк, а вёрстка блока не ломается
+
diff --git a/web/templates/partials/download_main.html b/web/templates/partials/download_main.html
index 01ec57c..4f68f67 100644
--- a/web/templates/partials/download_main.html
+++ b/web/templates/partials/download_main.html
@@ -37,7 +37,7 @@
Тип{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}
{{if .IsSeries}}Сезон{{if .Season}}{{.Season}}{{else}}—{{end}}{{end}}
Год{{if .Year}}{{.Year}}{{else}}—{{end}}
- Режиссёр—
+ Режиссёр{{if .Director}}{{.Director}}{{else}}—{{end}}
База{{if .Provider}}{{if .MatchURL}}{{.Provider}} · {{.ProviderID}} ↗{{else}}{{.Provider}} · {{.ProviderID}}{{end}}{{else if .NoBase}}без базы{{else}}—{{end}}
Уверенность{{if .Confidence}}{{.Confidence}}{{else}}—{{end}}