From 5948f4d21916386fecf6ce7492cc4c075fcfd681 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Fri, 17 Jul 2026 21:21:27 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D0=B1-UI:=20=D1=80=D0=B5=D0=B6?= =?UTF-8?q?=D0=B8=D1=81=D1=81=D1=91=D1=80=20=D0=B2=20=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=D0=B5=20=C2=AB=D0=A0=D0=B0=D1=81=D0=BF=D0=BE=D0=B7=D0=BD?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=20=D0=BA=D0=B0=D0=BA=C2=BB=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B5=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На /download/{id} поле «Режиссёр» было захардкожено прочерком, хотя экран ревью режиссёра уже выводит: слоистое разрешение полей ярлыка было заперто в неэкспортируемом worker.effectiveDisplayName. Из-за этого билдеры вью видели только слой распознавания+матч (rd.Plan.Director) без слоя контекста — то же на экране ревью. Вынес разрешение в экспортируемую naming.EffectiveFields(parsedContext, plan) LabelFields с методом Label(): выбор слоя по сырым значениям (как прежде), выбранные скаляры возвращаются очищенными (sanitize идемпотентен, display_name побайтно тот же). effectiveDisplayName стал тонкой обёрткой; страница загрузки и экран ревью берут режиссёра из той же функции — согласованно с заголовком. OpenSpec: web-ui (ADDED «Режиссёр в блоке распознавания страницы загрузки»), review (MODIFIED «Инфо и предпросмотр выбранного источника» — слоистое разрешение с фолбэком на контекст). Change заархивирован. Беклог: закрыта rezhisser-v-kartochke-zagruzki. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/backlog/README.md | 1 - .../backlog/rezhisser-v-kartochke-zagruzki.md | 50 ------- internal/httpapi/download.go | 6 + internal/httpapi/render_test.go | 50 +++++++ internal/httpapi/review.go | 6 +- internal/naming/effective_test.go | 112 +++++++++++++++ internal/naming/naming.go | 57 ++++++++ internal/worker/review.go | 33 +---- .../design.md | 132 ++++++++++++++++++ .../proposal.md | 60 ++++++++ .../specs/review/spec.md | 49 +++++++ .../specs/web-ui/spec.md | 40 ++++++ .../tasks.md | 37 +++++ openspec/specs/review/spec.md | 31 ++-- openspec/specs/web-ui/spec.md | 39 +++++- web/templates/partials/download_main.html | 2 +- 16 files changed, 612 insertions(+), 93 deletions(-) delete mode 100644 docs/backlog/rezhisser-v-kartochke-zagruzki.md create mode 100644 internal/naming/effective_test.go create mode 100644 openspec/changes/archive/2026-07-17-download-card-director/design.md create mode 100644 openspec/changes/archive/2026-07-17-download-card-director/proposal.md create mode 100644 openspec/changes/archive/2026-07-17-download-card-director/specs/review/spec.md create mode 100644 openspec/changes/archive/2026-07-17-download-card-director/specs/web-ui/spec.md create mode 100644 openspec/changes/archive/2026-07-17-download-card-director/tasks.md 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}}