Ревью: единый блок выбора источника, клик = выбор (review-unified-source-block)

Три секции экрана ревью (Догадка/Источник/Раскладка) слиты в один блок:
список вариантов (радио) → инфо о выбранном → предпросмотр раскладки. Клик
по варианту сразу выбирает и сохраняет источник и обновляет инфо+раскладку
частичным htmx-свопом блока, без полной перезагрузки и без кнопки «выбрать».

- httpapi: reviewBlockAction (htmx-aware, детект HX-Request) для
  candidate/nobase/source; вынос buildReviewView; поля SeasonSummary и
  BlockError; сводка сезонов (seasonSummary/seasonRanges)
- тип movie↔series убран из UI (read-only); удалён веб-роут /type и
  handleSetType, метод SetType из интерфейса httpapi (worker/Telegram не тронуты)
- шаблон: партиал review_source_block, ссылка «запись ↗» вне кликабельного
  label, фокус радио с клавиатуры; чистка мёртвого sourceView.Files/IsSeries
- тесты: htmx-своп выбора, htmx-путь ошибки, юнит-тесты сводки сезонов
- openspec: спеки review/web-ui синхронизированы, change заархивирован
- беклог: сложные сериальные раздачи; oob-обновление панели действий

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-04 08:12:28 +03:00
co-authored by Claude Opus 4.8
parent 2ed9c9020f
commit 1f4267a046
17 changed files with 942 additions and 157 deletions
+33
View File
@@ -236,6 +236,26 @@ Web-сторона реализована: страница загрузки `/d
Связано: [review-ux.md](specs/review-ux.md), [recognition.md](specs/recognition.md)
(матч в базе), [architecture.md](specs/architecture.md) → «Транспорты».
### Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки _(идея)_
Обычный случай сериальной раздачи — один сезон (его номер надо сразу видеть
глазами и сверять на ревью — под это сделана сводка сезонов в инфо-части, см.
`openspec/specs/review`). Но в редких заказах раздача бывает сложнее: **все
сезоны сериала разом**, **пак нескольких сезонов**, смешанная нумерация, вложенные
папки сезонов, разнобойные имена файлов. Сейчас `PlanFile.Season` задаётся на
каждом файле (мультисезон в принципе выразим), но целостно эти сценарии не
проработаны: как надёжно распознать многосезонную раздачу, как показать её на
ревью (сводка — лишь страховка, не полноценный разбор по сезонам), как разложить
и как это стыкуется со сходимостью папки и merge-докачиванием. Проработать
крайние случаи и решить, что поддерживаем явно, а что уводим в ревью как «сложную
раскладку».
Связано: [recognition.md](specs/recognition.md) (сезон-паки, нумерация),
[jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка сезонов),
[review-ux.md](specs/review-ux.md) (крайние сценарии, сводка сезонов),
[«Проблема второго сезона»](#проблема-второго-сезона),
[«Раздачи с докачиванием»](#раздачи-с-докачиванием-слияние-при-повторном-добавлении).
### Аниме с абсолютной нумерацией
Релизы аниме часто нумеруют серии сквозным числом (`#137`) без сезонов, а
@@ -368,6 +388,19 @@ URL кандидата хардкодит `.../dereferrer/series/{id}` (там
`openspec/specs/metadata-match` (требование «Кандидат несёт URL»), пакеты
`metadata`, `httpapi`.
### Панель действий ревью вне htmx-свопа блока источника
При выборе источника одним кликом обновляется только блок источника
(`#source-block`) htmx-свопом, а нижняя панель действий (кнопка «Применить»,
завязанная на `HasLinks`) — вне блока и не обновляется до полной перезагрузки.
Практически не мешает (хардлинки только по явному «Применить»,
`Apply` без плана вернёт ошибку), но в краевом случае (источник с пустым
предпросмотром из-за коллизии) кнопка «Применить» может остаться/пропасть не
синхронно. Решение намечено в дизайне `review-unified-source-block`
(Risks/Trade-offs): обновлять панель `hx-swap-oob` из того же партиала.
Связано: `openspec/specs/review`, `openspec/specs/web-ui`, пакет `httpapi`.
### Мгновенные обновления через SSE
Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто
+67
View File
@@ -1,6 +1,10 @@
package httpapi
import (
"sort"
"strconv"
"strings"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/recognize"
)
@@ -36,6 +40,69 @@ func buildFileRows(plan recognize.Plan, preview []layout.Link) []fileRow {
return rows
}
// seasonSummary собирает верхнеуровневую сводку сезонов сериальной раздачи по
// эпизодным файлам плана. Сезон задан на файле (мультисезонные паки), поэтому
// сводим множество различных сезонов; Season nil/0 — спецвыпуски. Примеры:
// «Сезон 2», «Сезоны 1–3», «Сезоны 1, 3–4», «Спецвыпуски», «Сезоны 1–2, спецвыпуски».
func seasonSummary(plan recognize.Plan) string {
seen := map[int]bool{}
specials := false
for _, f := range plan.Files {
if f.Role != recognize.RoleEpisode {
continue
}
n := 0
if f.Season != nil {
n = *f.Season
}
if n <= 0 {
specials = true
continue
}
seen[n] = true
}
nums := make([]int, 0, len(seen))
for n := range seen {
nums = append(nums, n)
}
sort.Ints(nums)
var parts []string
switch {
case len(nums) == 1:
parts = append(parts, "Сезон "+strconv.Itoa(nums[0]))
case len(nums) > 1:
parts = append(parts, "Сезоны "+seasonRanges(nums))
}
if specials {
if len(parts) == 0 {
parts = append(parts, "Спецвыпуски")
} else {
parts = append(parts, "спецвыпуски")
}
}
return strings.Join(parts, ", ")
}
// seasonRanges схлопывает возрастающие номера сезонов в диапазоны:
// [1,2,3] → «13», [1,3,4] → «1, 34».
func seasonRanges(nums []int) string {
var out []string
for i := 0; i < len(nums); {
j := i
for j+1 < len(nums) && nums[j+1] == nums[j]+1 {
j++
}
if j == i {
out = append(out, strconv.Itoa(nums[i]))
} else {
out = append(out, strconv.Itoa(nums[i])+""+strconv.Itoa(nums[j]))
}
i = j + 1
}
return strings.Join(out, ", ")
}
// roleLabel — человекочитаемая роль файла раскладки.
func roleLabel(role string) string {
switch role {
+53
View File
@@ -0,0 +1,53 @@
package httpapi
import (
"testing"
"git.vakhrushev.me/av/jellybit/internal/recognize"
)
func TestSeasonSummary(t *testing.T) {
// ep — эпизодный файл с заданным (или nil) сезоном.
ep := func(season *int) recognize.PlanFile {
return recognize.PlanFile{Role: recognize.RoleEpisode, Season: season}
}
n := func(v int) *int { return &v }
cases := []struct {
name string
files []recognize.PlanFile
want string
}{
{"пусто", nil, ""},
{"один сезон", []recognize.PlanFile{ep(n(2)), ep(n(2))}, "Сезон 2"},
{"диапазон", []recognize.PlanFile{ep(n(1)), ep(n(2)), ep(n(3))}, "Сезоны 13"},
{"разрыв", []recognize.PlanFile{ep(n(1)), ep(n(3)), ep(n(4))}, "Сезоны 1, 34"},
{"несортированный вход", []recognize.PlanFile{ep(n(3)), ep(n(1)), ep(n(2))}, "Сезоны 13"},
{"только спецвыпуски (nil)", []recognize.PlanFile{ep(nil)}, "Спецвыпуски"},
{"только спецвыпуски (0)", []recognize.PlanFile{ep(n(0))}, "Спецвыпуски"},
{"сезоны и спецвыпуски", []recognize.PlanFile{ep(n(1)), ep(n(2)), ep(nil)}, "Сезоны 1–2, спецвыпуски"},
{"один сезон и спецвыпуски", []recognize.PlanFile{ep(n(1)), ep(nil)}, "Сезон 1, спецвыпуски"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := seasonSummary(recognize.Plan{Files: c.files})
if got != c.want {
t.Errorf("seasonSummary = %q, want %q", got, c.want)
}
})
}
}
// Не-эпизодные файлы (main/subtitle/…) не влияют на сводку сезонов.
func TestSeasonSummary_IgnoresNonEpisodes(t *testing.T) {
s := 2
plan := recognize.Plan{Files: []recognize.PlanFile{
{Role: recognize.RoleEpisode, Season: &s},
{Role: recognize.RoleMain},
{Role: recognize.RoleSubtitle},
{Role: recognize.RoleIgnore},
}}
if got := seasonSummary(plan); got != "Сезон 2" {
t.Errorf("seasonSummary = %q, want «Сезон 2»", got)
}
}
-1
View File
@@ -114,7 +114,6 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Post("/ui/downloads/{id}/apply", s.handleApply)
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize)
r.Post("/ui/downloads/{id}/type", s.handleSetType)
r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
+78 -14
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
@@ -395,7 +396,6 @@ type fakeReviewer struct {
data *worker.ReviewData
applyErr error
refined map[string]string
typed map[string]string
ignored map[string]string
chosen map[string]string
providerSet map[string]string
@@ -425,13 +425,6 @@ func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
f.refined[id] = hint
return nil
}
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
if f.typed == nil {
f.typed = map[string]string{}
}
f.typed[id] = t
return nil
}
func (f *fakeReviewer) IgnoreFile(_ context.Context, id string, src string) error {
if f.ignored == nil {
f.ignored = map[string]string{}
@@ -541,11 +534,16 @@ func TestReviewRenders(t *testing.T) {
}
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
"Season 02", "Применить", "Уточнить",
"Источник совпадения", "269613", "выбрать", "распознано нейронкой", "Добавить"} {
"Источник и раскладка", "269613", "распознано нейронкой", "Добавить",
"Сезон 2"} {
if !strings.Contains(string(body), want) {
t.Errorf("страница ревью не содержит %q", want)
}
}
// Кнопки «выбрать» больше нет — выбор одним кликом по радио.
if strings.Contains(string(body), ">выбрать<") {
t.Error("страница ревью всё ещё содержит кнопку «выбрать»")
}
}
func TestReviewShowsMatchLink(t *testing.T) {
@@ -653,6 +651,45 @@ func TestAddManualSource_RejectsBadURL(t *testing.T) {
}
}
// TestAddManualSource_HTMXError: невалидный ручной ввод на htmx-пути возвращает
// партиал блока с ошибкой (не редирект, не «внутренняя ошибка»), источник не
// добавлен.
func TestAddManualSource_HTMXError(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
form := url.Values{"provider": {"tvdb"}, "provider_id": {"https://www.thetvdb.com/series/fargo"}}
req, err := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/source",
strings.NewReader(form.Encode()))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
resp, err := noRedirectClient().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (партиал с ошибкой)", resp.StatusCode)
}
if _, called := rv.sourceAdded[tid]; called {
t.Errorf("невалидный ввод не должен вызывать AddManualSource: %v", rv.sourceAdded)
}
if !strings.Contains(string(body), `id="source-block"`) {
t.Error("htmx-ответ не содержит блок источника")
}
if !strings.Contains(string(body), "block-error") {
t.Error("htmx-ответ не содержит баннер ошибки блока")
}
if strings.Contains(string(body), "внутренняя ошибка") {
t.Error("ошибка ввода подана как внутренняя")
}
}
func TestApplyRedirectsToIndex(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
@@ -708,7 +745,7 @@ func TestRefinePostsHint(t *testing.T) {
}
}
func TestIgnoreAndType(t *testing.T) {
func TestIgnoreFile(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
@@ -721,13 +758,40 @@ func TestIgnoreAndType(t *testing.T) {
if rv.ignored[tid] != "Fargo/sample.mkv" {
t.Errorf("IgnoreFile получил %q", rv.ignored[tid])
}
}
if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/type",
map[string][]string{"type": {"movie"}}); err != nil {
// TestChooseCandidateHTMX: на htmx-запрос выбор возвращает партиал блока
// источника (а не полную страницу и не редирект), обновлённый под выбор.
func TestChooseCandidateHTMX(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
req, err := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/candidate",
strings.NewReader("candidate_id="+cid))
if err != nil {
t.Fatal(err)
}
if rv.typed[tid] != "movie" {
t.Errorf("SetType получил %q", rv.typed[tid])
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
resp, err := noRedirectClient().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (партиал, не редирект)", resp.StatusCode)
}
if rv.chosen[tid] != cid {
t.Errorf("ChooseCandidate получил %q", rv.chosen[tid])
}
// Ответ — партиал блока, а не полная страница.
if strings.Contains(string(body), "<!doctype html>") {
t.Error("htmx-ответ должен быть партиалом, а не полной страницей")
}
if !strings.Contains(string(body), `id="source-block"`) {
t.Error("htmx-ответ не содержит корневой контейнер блока #source-block")
}
}
+63 -19
View File
@@ -18,7 +18,6 @@ type Reviewer interface {
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
Apply(ctx context.Context, id string) error
Refine(ctx context.Context, id string, hint string) error
SetType(ctx context.Context, id string, mediaType string) error
IgnoreFile(ctx context.Context, id string, src string) error
Defer(ctx context.Context, id string) error
Undo(ctx context.Context, id string) error
@@ -44,6 +43,7 @@ type reviewView struct {
Title string
OriginalTitle string
Year int
SeasonSummary string // сводка сезонов для сериала (пусто для фильма)
Provider string
ProviderID string
MatchURL string // ссылка на подтверждённую запись метабазы (пусто — текстом)
@@ -55,10 +55,13 @@ type reviewView struct {
HasLinks bool // есть хотя бы один целевой путь → можно применять
NoBase bool // выбрано «без базы»
Sources []sourceView // единый список источников совпадения
BlockError string // ошибка выбора внутри блока (htmx); не путать с Error (?err=)
}
// sourceView — строка единого списка источников на экране ревью: нейронка или
// кандидат базы, с эффективными полями и предпросмотром целевых путей.
// кандидат базы. Инфо и предпросмотр раскладки показываются для активного
// источника из верхнеуровневых полей reviewView, поэтому per-source превью
// строка не несёт.
type sourceView struct {
Kind string // "neural" | "candidate"
CandidateID string
@@ -66,10 +69,8 @@ type sourceView struct {
ProviderID string
Title string
Year int
IsSeries bool
MatchURL string
Active bool
Files []fileRow // предпросмотр «файл → раскладка» этого источника
}
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
@@ -90,12 +91,20 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
return
}
s.render(w, "review.html", buildReviewView(id, rd, r.URL.Query().Get("err")))
}
// buildReviewView собирает представление страницы ревью из доменных данных.
// Общий для полной страницы (handleReview) и htmx-свопа блока источника
// (reviewBlockAction); errMsg — верхний баннер из ?err= (пусто на htmx-пути,
// там ошибка идёт в BlockError).
func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView {
view := reviewView{
ID: id,
Source: shorten(rd.Download.SourceRef, 80),
Context: rd.Download.Context,
State: string(rd.Download.State),
Error: r.URL.Query().Get("err"),
Error: errMsg,
StateError: rd.Download.ErrorMsg.String,
Hints: rd.Hints,
}
@@ -105,6 +114,9 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
view.Title = rd.Plan.Title
view.OriginalTitle = rd.Plan.OriginalTitle
view.Year = rd.Plan.Year
if view.IsSeries {
view.SeasonSummary = seasonSummary(rd.Plan)
}
view.Reasons = rec.ReasonList()
switch rd.Provider {
case "", "none":
@@ -128,9 +140,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
ProviderID: src.ProviderID,
Title: src.Title,
Year: src.Year,
IsSeries: src.Type == "series",
Active: src.Active,
Files: buildFileRows(src.Plan, src.Preview),
}
if src.Kind == worker.SourceCandidate {
sv.MatchURL = sourceMatchURL(src)
@@ -138,8 +148,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
view.Sources = append(view.Sources, sv)
}
}
s.render(w, "review.html", view)
return view
}
// --- Действия ревью (POST → redirect) ---
@@ -172,13 +181,6 @@ func (s *server) handleRerecognize(w http.ResponseWriter, r *http.Request) {
})
}
func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id string) error {
_ = r.ParseForm()
return s.deps.Reviewer.SetType(ctx, id, r.PostForm.Get("type"))
})
}
func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id string) error {
_ = r.ParseForm()
@@ -187,7 +189,7 @@ func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) {
}
func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id string) error {
s.reviewBlockAction(w, r, func(ctx context.Context, id string) error {
_ = r.ParseForm()
// Входная граница: id кандидата из формы валидируется как ULID.
candidateID, err := ident.Parse(r.PostForm.Get("candidate_id"))
@@ -206,7 +208,7 @@ func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) {
}
func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id string) error {
s.reviewBlockAction(w, r, func(ctx context.Context, id string) error {
return s.deps.Reviewer.ClearProvider(ctx, id)
})
}
@@ -214,7 +216,7 @@ func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) {
// handleAddSource добавляет источник вручную по id или URL записи метабазы и
// выбирает его. Разбор ввода — на входной границе транспорта.
func (s *server) handleAddSource(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id string) error {
s.reviewBlockAction(w, r, func(ctx context.Context, id string) error {
_ = r.ParseForm()
provider, providerID, err := parseManualSource(r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
if err != nil {
@@ -364,6 +366,48 @@ func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(co
redirectReview(w, r, id, "")
}
// isHTMX — запрос инициирован htmx (ждёт партиал, а не полную страницу).
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// reviewBlockAction — помощник для действий выбора источника: выполнить
// операцию и вернуть свежий блок источника. На htmx-запрос перечитывает
// состояние и рендерит партиал `review_source_block` (ошибку кладёт в
// BlockError, активный источник не меняется); без htmx деградирует до
// PRG-редиректа, как reviewAction.
func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
actionErr := fn(r.Context(), id)
if !isHTMX(r) {
msg := ""
if actionErr != nil {
msg = userErr(r, actionErr, id)
}
redirectReview(w, r, id, msg)
return
}
// htmx: перечитываем состояние (уже с новым активным источником при успехе)
// и рендерим свежий партиал блока.
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil {
s.deps.Logger.Error("review data", "id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return
}
view := buildReviewView(id, rd, "")
if actionErr != nil {
view.BlockError = userErr(r, actionErr, id)
}
s.render(w, "review_source_block", view)
}
// matchURL выбирает ссылку на подтверждённую запись метабазы. Приоритет — URL
// выбранного кандидата, но только если его provider+id совпадают с эффективными
// (человек мог выбрать кандидата, затем вручную переопределить id — тогда
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-04
@@ -0,0 +1,177 @@
## Context
Экран ревью (`web/templates/review.html` + `internal/httpapi/review.go`) сейчас
состоит из трёх секций:
- **Догадка** — тип (переключатель movie↔series, POST `/type`), название, год;
- **Источник совпадения** — единый список вариантов (`.Sources`); у каждого
не-активного варианта — нативный `<details>` с предпросмотром раскладки, и
отдельная кнопка «выбрать» (POST `/candidate` или `/nobase`); ниже — форма
ручного добавления (POST `/source`);
- **Раскладка** — та же таблица предпросмотра, но для активного источника.
Все действия — обычные `<form method=post>` c PRG-редиректом (`reviewAction`
`redirectReview`, `303`). htmx подключён (`review.html:8`), но на странице ревью
не используется; на других страницах он уже применяется для фрагментов
(`hx-get .../progress`, `every 3s`).
Домен уже отдаёт всё нужное: `worker.ReviewData` строит `Sources []SourceOption`
(нейронка + кандидаты), у каждого — эффективные поля и эфемерный предпросмотр;
верхнеуровневые поля `reviewView` (`Title/Year/Files/...`) уже соответствуют
**активному** источнику. Инвариант «превью == применённое» обеспечивается тем,
что выбор источника пишет те же пины, что показаны в превью.
## Goals / Non-Goals
**Goals:**
- Слить три секции в один блок: список вариантов (радио) → инфо о выбранном →
предпросмотр раскладки выбранного.
- Выбор варианта — одним кликом/тапом по строке; инфо и предпросмотр
обновляются немедленно, без полной перезагрузки (htmx частичный своп блока).
- Тип показывать read-only; убрать переключатель типа с веб-экрана.
- Сохранить: ручное добавление источника, инвариант «превью == применённое»,
тонкость транспорта (доменная логика в `worker` не трогается).
**Non-Goals:**
- Менять доменный слой `internal/worker/review.go` (выбор источника, построение
плана/превью остаются как есть).
- Менять команду `SetType` в домене и её доступность в Telegram (убираем только
веб-контрол).
- Клиентский рефреймворк/сборка. Оптимизация «не считать превью для не-активных
источников» — отдельная будущая задача, не входит сюда.
## Decisions
### Решение 1: htmx частичный своп единого блока (не полная перезагрузка)
Выделяем единый блок в партиал `web/templates/partials/review_source_block.html`
с корневым контейнером `id="source-block"`. Партиал рендерится из того же
`reviewView`: список радио из `.Sources`, инфо — из верхнеуровневых полей
активного источника (`.Title/.OriginalTitle/.Year/.IsSeries/.SeasonSummary`),
предпросмотр — из `.Files`.
Радиокнопка варианта несёт htmx-атрибуты: `hx-trigger="change"`,
`hx-target="#source-block"`, `hx-swap="outerHTML"` и `hx-post` на эндпоинт
выбора. Клик по строке (label оборачивает кликабельную зону строки) переключает
радио → `change` → POST → сервер сохраняет выбор и возвращает **свежий партиал
блока** → htmx подменяет блок. Инфо и предпросмотр в новом партиале уже
относятся к новому активному источнику.
Все радио вариантов имеют **общий `name`** для взаимной эксклюзивности;
кандидатские несут `value`=`candidate_id` и постят на `/candidate`, нейронка —
пустое `value` и постит на `/nobase` (тот `candidate_id` игнорирует).
**Внешняя ссылка «запись ↗»** у кандидата (открывается в новой вкладке) НЕ
должна попадать в кликабельную зону label — иначе клик по ссылке заодно
переключит источник. Выносим ссылку из `<label>` (или гасим всплытие клика),
чтобы «перейти к записи» и «выбрать источник» не конфликтовали.
**Почему так, а не клиентское переключение:** сохранение выбора — доменная
операция (пишет override/пины), поэтому нужен раундтрип; после него активный
источник и его превью пересчитываются на сервере единой логикой (инвариант
«превью == применённое» держится сам собой). Чистый клиентский свитч потребовал
бы дублировать превью-логику и рассинхронизировался бы с применением.
**Альтернатива (отклонено):** заранее рендерить инфо+превью всех источников и
показывать активный через CSS/JS без запроса. Отклонено: выбор не сохранялся бы,
«Применить» не знал бы что применять, и вернулась бы рассинхронизация
превью/применения.
### Решение 2: эндпоинты выбора становятся htmx-aware, без новых роутов
Переиспользуем существующие POST-эндпоинты `/candidate`, `/nobase`, `/source`.
Радио кандидата постит на `/candidate` (поле `candidate_id` = value радио), радио
нейронки — на `/nobase`, форма ручного добавления — на `/source` (тоже
`hx-post`, target = `#source-block`).
Хендлеры (`handleChooseCandidate`, `handleNoBase`, `handleAddSource`) после
успешной доменной операции определяют htmx-запрос по заголовку `HX-Request` и:
- при htmx — перечитывают `ReviewData`, рендерят **партиал блока** (`200`);
- без htmx (фолбек) — как сейчас, PRG-редирект на `/review/{id}`.
Это **новый паттерн** для проекта: существующие живые партиалы (`progress`,
`seeding`) работают через отдельные GET-роуты `/fragments/...` с htmx-поллингом,
а не через ветвление одного POST-эндпоинта по `HX-Request` — так что чтение
`r.Header.Get("HX-Request")` вводится здесь впервые. Сам механизм рендера одного
партиала уже есть: `server.render(w, "<name>", data)` вызывает
`ExecuteTemplate` по имени define (как `render(w, "progress", …)`), никаких
правок в `render` не нужно.
Ошибку (напр. невалидный ручной ввод) на htmx-пути рендерим тем же партиалом с
баннером ошибки **внутри блока** и **без смены активного источника**
(перечитанный `ReviewData` отражает прежний матч). Чтобы не задваивать баннер с
уже существующим верхним `?err=` (его показывают PRG-редиректы других действий —
apply/defer/cancel), ошибку блока держим в **отдельном поле** view (напр.
`BlockError`), которое рендерит только партиал; верхний `.Error` остаётся для
полностраничного `?err=`. Общий помощник — по образцу `reviewAction`, но с
ветвлением htmx/redirect (напр. `reviewBlockAction`).
**Почему не новый единый роут `/select`:** минимизируем изменения и
переиспользуем валидацию и доменные вызовы; семантика «кандидат» vs «без базы»
уже разведена по эндпоинтам.
### Решение 3: тип — read-only, веб-контрол `/type` убираем
Из шаблона убираем форму переключения типа; тип показываем текстом
(`фильм`/`сериал`) в инфо-части. Роут `/type` и `handleSetType` в `httpapi`
удаляем (веб — единственный их потребитель; Telegram вызывает `worker.SetType`
напрямую, доменный метод остаётся). Перед удалением — убедиться grep'ом, что на
`/type`/`handleSetType` в `httpapi` больше никто не ссылается.
Корректировать тип пользователь по-прежнему может через «Уточнить» (мягкая
подсказка «это сериал»), что согласовано в модифицированном требовании «Команды
ревью и их эффекты».
### Решение 4: инфо-часть — состав полей
Инфо-часть выбранного источника: тип (read-only), название, ориг. название, год,
для сериала — **сводка сезонов**, плюс зарезервированное место под режиссёра
(пустой прочерк).
Сезон в плане задан **на каждом файле** (`recognize.PlanFile.Season *int`), а не
на плане целиком — одна раздача может быть многосезонным паком. Поэтому в
инфо-части показываем компактную сводку по различным сезонам эпизодных файлов
эффективного плана (`rd.Plan`):
- один сезон → «Сезон 2»;
- несколько подряд → «Сезоны 1–3» (диапазон), с разрывами → список «Сезоны 1,
3, 4»;
- только спецвыпуски (`Season == nil`/0) → «Спецвыпуски».
Сводку собираем в транспорте из `rd.Plan.Files` по файлам с ролью `episode`
(игнор-файлы `applyOverrides` уже пометил ролью `ignore` — они выпадают из
фильтра). `PlanFile.Season``*int`, где и `nil`, и `*0` трактуются как
спецвыпуск. Добавляем в `reviewView` строковое поле `SeasonSummary string`
(пусто для фильма). Номер сезона построчно и так виден в предпросмотре раскладки
(`.../Season 02/...`); сводка — это верхнеуровневая подпись-страховка «что за
сезоны в раздаче», обычный случай — один сезон.
## Risks / Trade-offs
- **[Выбор одним кликом требует htmx/JS — нет чистого no-JS фолбека выбора]** →
htmx всегда загружен, инструмент однопользовательский домашний; прочие
действия (Применить/Уточнить/Позже/Отклонить) остаются обычными формами и
работают без JS; хендлеры сохраняют redirect-фолбек, так что без htmx выбор
деградирует до перезагрузки, а не ломается (radio без submit-кнопки, впрочем,
без JS не отправится — это осознанный компромисс UI-мелочи).
- **[Случайный клик меняет сохранённый матч]** → эффект не разрушительный
(хардлинки только по «Применить»), возврат — один клик по другому варианту.
- **[Панель действий вне свопаемого блока может рассинхрониться]** (`Применить`
зависит от `HasLinks`) → на практике план/превью есть всегда, когда есть
активный источник, поэтому набор действий при переключении источников не
меняется; если понадобится — обновляем панель через `hx-swap-oob` из того же
партиала.
- **[Мобильный тап]** → строка-вариант должна иметь крупную кликабельную зону
(label оборачивает всю строку), проверить на узком экране.
## Migration Plan
Чистая замена рендера страницы ревью; данные/БД не затрагиваются, миграций нет.
Откат — возврат шаблона и хендлеров. Деплой — обычная пересборка бинаря.
## Open Questions
- Нет — открытые вопросы закрыты (сезон показываем сводкой, см. Решение 4).
@@ -0,0 +1,58 @@
## Why
Экран ревью разбит на три секции («Догадка», «Источник совпадения»,
«Раскладка»), которые дублируют друг друга: у каждого варианта в списке уже есть
свой `<details>`-предпросмотр раскладки, а внизу та же раскладка повторяется для
активного источника. Выбор варианта требует лишнего действия — раскрыть
предпросмотр, затем нажать отдельную кнопку «выбрать». По сути это один шаг —
«выбрать источник и увидеть его параметры и раскладку», — растянутый на три
блока и два клика.
## What Changes
- Три секции ревью (`Догадка`, `Источник совпадения`, `Раскладка`)
объединяются в **единый блок выбора источника** из трёх частей:
1. список вариантов-«провайдеров» (нейронка + записи метабаз) с
радиокнопками;
2. инфо о выбранном варианте (название, ориг. название, год, тип
фильм/сериал, для сериала — сезон);
3. предпросмотр раскладки выбранного варианта.
- **Клик/тап по варианту сразу выбирает и сохраняет его** (как сейчас кнопка
«выбрать»): отдельной кнопки «выбрать» больше нет. Инфо и предпросмотр
обновляются немедленно через htmx-swap блока, без полной перезагрузки
страницы.
- **BREAKING (UI)**: переключатель типа `фильм/сериал` убирается из экрана
ревью — тип показывается read-only в инфо-части выбранного варианта.
Корректировка типа при необходимости остаётся доступной через «Уточнить»
(мягкая подсказка).
- Форма ручного добавления источника (`TMDB`/`TVDB`/`IMDb` по id или URL)
сохраняется — под списком вариантов; добавленный источник появляется как
выбираемая строка.
- Инвариант «предпросмотр == применённое» сохраняется: показанная раскладка
выбранного источника идентична тому, что создаст «Применить».
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `review`: смена активного источника — одним кликом по строке варианта (а не
отдельной кнопкой), с немедленным обновлением инфо и предпросмотра;
предпросмотр полей и раскладки показывается для выбранного (активного)
источника; команда «Тип» убирается с экрана (тип read-only).
- `web-ui`: экран ревью рендерит единый блок выбора источника; предпросмотр
раскладки строится для выбранного источника и обновляется частичным
htmx-свопом блока при смене выбора.
## Impact
- `internal/httpapi/review.go` — view-модели (`reviewView`, `sourceView`),
`handleReview`; хендлер выбора источника возвращает партиал блока (htmx),
а не только redirect; удаление/отключение UI-ветки `handleSetType`.
- `web/templates/review.html` + новый партиал блока источника (для htmx-свопа).
- `web/static/css/jellybit.css` — стили объединённого блока.
- Доменный слой `internal/worker/review.go` не меняется (тот же выбор
источника и построение предпросмотра); меняется только транспорт/шаблон.
@@ -0,0 +1,132 @@
## MODIFIED Requirements
### Requirement: Команды ревью и их эффекты
Экран ревью SHALL предоставлять команды: **Применить** (создать хардлинки по
эффективному плану), **Уточнить** (добавить подсказку → перераспознать),
**Распознать заново** (повторный прогон без новой подсказки), **Игнор файла**,
**Позже** (`deferred`), **Отклонить** (`cancelled`), **Undo** (снять созданные
ссылки → `reverted`) и **Привязать заново** (из
`reverted`/`cancelled`/`target_missing` → перераспознавание с ручным
подтверждением). Экран ревью MUST NOT содержать команду переключения типа
movie↔series: тип показывается read-only, а его корректировка выполняется
мягкой подсказкой через **Уточнить**. Команды из любого транспорта SHALL
сериализоваться worker'ом под единой блокировкой; применяется последняя валидная
команда. Команды, которым нужен источник, SHALL проверять его наличие синхронно
перед действием.
#### Scenario: Применение создаёт раскладку
- **GIVEN** загрузка в `review` с эффективным планом
- **WHEN** пользователь выбирает «Применить»
- **THEN** создаются хардлинки по плану, задача переходит к раскладке
#### Scenario: Отклонить и привязать заново
- **GIVEN** загрузка в `review`
- **WHEN** пользователь «Отклонить», затем «Привязать заново»
- **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным
подтверждением (авто-раскладка не делается)
#### Scenario: Тип не переключается кнопкой
- **GIVEN** загрузка в `review` с распознанным типом
- **WHEN** пользователь открывает экран ревью
- **THEN** отдельной команды/кнопки переключения movie↔series на экране нет
- **AND** тип показан read-only в инфо-части выбранного источника
### Requirement: Единый список источников совпадения на ревью
Экран ревью (`/review/{id}`) SHALL показывать совпавшие источники **единым
списком**, в котором распознавание нейронкой (без базы) — такая же строка,
как кандидаты метабаз (TMDB/TVDB/TVMaze), а не отдельный режим сверху.
Ровно один источник в списке SHALL быть отмечен активным (эффективный
матч). Активный источник SHALL выбираться **одним кликом/тапом по строке
варианта** (радиокнопка), без отдельной кнопки подтверждения выбора. Выбор
источника SHALL сохранять его как эффективный матч (persist) и SHALL
выполняться через раундтрип на сервер (форма/htmx), без клиентского пересчёта
доменного состояния; при этом инфо-часть и предпросмотр раскладки SHALL
немедленно обновляться под выбранный источник (частичный своп блока, без полной
перезагрузки страницы). Экран SHALL позволять операции над этим списком:
выбрать кандидата базы, переключиться на другого кандидата и снять матч с базы
обратно на нейронку («без базы»). Список источников SHALL показываться только
при наличии плана распознавания.
#### Scenario: Нейронка — строка в общем списке
- **GIVEN** загрузка в `review` с распознаванием нейронкой и одним или
несколькими кандидатами метабаз
- **WHEN** пользователь открывает `GET /review/{id}`
- **THEN** источники показаны единым списком, где строка «распознано
нейронкой» стоит наравне с кандидатами баз
- **AND** активным отмечен ровно один источник (текущий эффективный матч)
#### Scenario: Выбор кандидата одним кликом
- **GIVEN** на экране ревью выбран один кандидат метабазы
- **WHEN** пользователь кликает/тапает строку другого кандидата
- **THEN** выбранный кандидат сохраняется активным, прочие — неактивны, без
отдельного нажатия кнопки «выбрать»
- **AND** инфо-часть и предпросмотр раскладки сразу обновляются под выбранного
кандидата без полной перезагрузки страницы
#### Scenario: Снятие матча в пользу нейронки
- **GIVEN** на экране ревью активен кандидат метабазы с названием «Fargo»
- **WHEN** пользователь кликает строку «распознано нейронкой»
- **THEN** матч с базой снимается (источник — нейронка, «без базы»), тег
папки провайдера не проставляется
- **AND** поля источника — из распознавания нейронкой, без унаследованных
от прежнего кандидата название/год
## ADDED Requirements
### Requirement: Инфо и предпросмотр выбранного источника
В едином блоке выбора источника экран ревью SHALL показывать для **выбранного
(активного)** источника две части: **инфо** — тип (read-only, movie/series),
название, оригинальное название, год, для сериала — сводку сезонов (один сезон,
диапазон/список для многосезонного пака или «Спецвыпуски»), с
зарезервированным местом под режиссёра; и **предпросмотр раскладки** — целевые
пути хардлинков этого источника. Обе части SHALL относиться именно к активному
источнику и SHALL обновляться при смене выбора. Отрисовка блока (показ инфо и
предпросмотра) MUST NOT создавать хардлинки: раскладка создаётся только явным
действием «Применить». Совпадение целевых путей предпросмотра с результатом
применения регулируется требованием «Превью раскладки через единую логику
именования» (`web-ui`).
#### Scenario: Инфо и предпросмотр относятся к активному источнику
- **GIVEN** в списке активен кандидат метабазы
- **WHEN** пользователь смотрит инфо-часть и предпросмотр раскладки
- **THEN** показаны тип, название, ориг. название, год (и сводка сезонов для
сериала) именно этого источника и предпросмотр его целевых путей
#### Scenario: Просмотр блока не создаёт раскладку
- **GIVEN** экран ревью с показанным блоком выбора источника
- **WHEN** пользователь только просматривает инфо и предпросмотр, не нажимая
«Применить»
- **THEN** хардлинки не создаются, файлы под `paths.movies`/`series` не
меняются
#### Scenario: Зарезервированное место под режиссёра
- **GIVEN** режиссёр из метабазы пока не загружается
- **WHEN** отображается инфо-часть выбранного источника
- **THEN** в ней присутствует место под режиссёра, показанное пустым (или
прочерком), не ломая вёрстку
## REMOVED Requirements
### Requirement: Предпросмотр полей источника до фиксации выбора
**Reason**: Модель «раскрыть предпросмотр не-активного источника, затем нажать
«выбрать»» заменяется на «клик по варианту = выбор». Предпросмотр полей и
раскладки теперь показывается только для выбранного (активного) источника и
обновляется при смене выбора.
**Migration**: Поведение перенесено в требования «Единый список источников
совпадения на ревью» (выбор одним кликом, немедленное обновление) и «Инфо и
предпросмотр выбранного источника» (что именно показывается и что показ не
создаёт хардлинки).
@@ -0,0 +1,35 @@
## MODIFIED Requirements
### Requirement: Превью раскладки через единую логику именования
Превью целевых путей раскладки в веб-UI SHALL вычисляться той же логикой
именования, что и реальная раскладка (`internal/naming`/`internal/layout`), а
не дублировать правила в шаблоне. На экране ревью превью SHALL строиться **для
выбранного (активного) источника** — эфемерно на сервере, без записи
сохранённого матча самим показом. При смене выбранного источника превью SHALL
пересчитываться под него и обновляться частичным свопом блока. Показанные для
источника пути MUST совпадать с теми, что создались бы при применении этого
источника.
#### Scenario: Превью совпадает с реальной раскладкой
- **WHEN** на экране ревью отображается превью целевых путей для выбранного
источника
- **THEN** эти пути идентичны тем, что создаст применение этого источника (те же
правила имён, спецвыпусков, мультифайла, запрещённых символов, тега провайдера
и коллизий)
#### Scenario: Смена источника пересчитывает превью
- **GIVEN** на экране ревью показан предпросмотр раскладки активного источника
- **WHEN** пользователь выбирает другой источник в списке
- **THEN** превью пересчитывается под выбранный источник и обновляется без
полной перезагрузки страницы
#### Scenario: Переключение источника не тянет чужие поля
- **GIVEN** активен кандидат с запиненными название/год, затем выбран
источник без собственных названия/года (нейронка или ручной кандидат)
- **WHEN** строится превью и затем выполняется применение выбранного источника
- **THEN** и превью, и применение используют название/год этого источника
(из плана распознавания), без унаследованных от прежнего кандидата
@@ -0,0 +1,70 @@
## 1. Транспорт: htmx-aware выбор источника
- [x] 1.1 Вынести построение `reviewView` из `handleReview` в общий хелпер
(напр. `buildReviewView(id, rd, errMsg)`), чтобы им пользовались и
`handleReview`, и `reviewBlockAction` (без дублирования логики сборки view).
- [x] 1.2 Добавить в `reviewView` поле `SeasonSummary string` (пусто для
фильма); собирать сводку по различным сезонам файлов `rd.Plan` с ролью
`episode` («Сезон 2» / «Сезоны 1–3» / «Сезоны 1, 3, 4» / «Спецвыпуски»),
учитывая что `Season == nil`/`0` — спецвыпуск.
- [x] 1.3 Добавить в `reviewView` отдельное поле `BlockError string` для ошибки
выбора внутри блока (не путать с верхним `.Error` из `?err=`).
- [x] 1.4 Ввести помощник `reviewBlockAction` (по образцу `reviewAction`): на
`r.Header.Get("HX-Request")` после успешной операции перечитать `ReviewData`,
отрендерить партиал блока (`200`); без htmx — прежний PRG-редирект. При ошибке
на htmx-пути — рендер партиала с `BlockError`, активный источник не меняется.
- [x] 1.5 Перевести `handleChooseCandidate`, `handleNoBase`, `handleAddSource`
на `reviewBlockAction`.
- [x] 1.6 Удалить веб-роут `POST /ui/downloads/{id}/type` и `handleSetType` из
`internal/httpapi`; grep'ом убедиться, что на них больше нет ссылок (Telegram
зовёт `worker.SetType` напрямую через свой интерфейс — доменный метод
оставить).
## 2. Шаблоны: единый блок выбора источника
- [x] 2.1 Создать партиал `web/templates/partials/review_source_block.html` с
корневым контейнером `id="source-block"`: (а) список вариантов из `.Sources`
радиокнопками, label оборачивает всю строку; (б) инфо-часть выбранного
источника (тип read-only, название, ориг. название, год, сезон для сериала,
место под режиссёра); (в) предпросмотр раскладки из `.Files`
(`{{template "layout_widget" .Files}}`).
- [x] 2.2 На радио вариантов повесить `hx-post` (кандидат → `/candidate` с
`candidate_id`; нейронка → `/nobase`), `hx-trigger="change"`,
`hx-target="#source-block"`, `hx-swap="outerHTML"`. Активный вариант отмечен
выбранным радио.
- [x] 2.3 Форму ручного добавления источника (`/source`) перенести под список
внутрь партиала; сделать её `hx-post` с тем же `hx-target`/`hx-swap`.
- [x] 2.4 На радио вариантов задать общий `name` (взаимная эксклюзивность):
кандидат — `value`=`candidate_id`, `hx-post` `/candidate`; нейронка — пустое
`value`, `hx-post` `/nobase`. Внешнюю ссылку «запись ↗» вынести из
кликабельной зоны label (или гасить всплытие), чтобы клик по ней не
переключал источник.
- [x] 2.5 В `review.html` заменить три секции (Догадка/Источник/Раскладка) на
`{{template "review_source_block" .}}`, сохранив обёртку `{{if .HasPlan}}`;
убрать per-source `<details>` и кнопки «выбрать», убрать форму переключения
типа.
- [x] 2.6 Баннер ошибки выбора рендерить внутри блока из `.BlockError`, чтобы
htmx-своп его показывал/очищал; верхний `.Error` (`?err=`) не трогать.
## 3. Стили
- [x] 3.1 Стили единого блока в `web/static/css/jellybit.css` (без инлайн-стилей
и хардкода цветов вне токенов): крупная кликабельная строка-вариант,
разделение частей блока (список → инфо → предпросмотр), корректный вид на
узком экране (мобильный тап).
## 4. Проверка
- [x] 4.0 Обновить тесты `internal/httpapi/httpapi_test.go`: ассерты на удаляемую
разметку («Источник совпадения», кнопка «выбрать») и тест `POST /type`
(`rv.typed`) — переписать под новый блок / удалить; добавить проверку
htmx-свопа выбора (ответ-партиал по `HX-Request`).
- [x] 4.1 `task build` / `task lint` / `task test` — зелёные.
- [x] 4.2 Прогнать вручную (или через verify): открыть ревью, кликнуть по
разным вариантам — инфо и предпросмотр обновляются без перезагрузки; выбор
сохраняется (перезагрузка страницы показывает тот же активный источник);
ручное добавление источника работает через htmx; «Применить» создаёт
раскладку, совпадающую с показанным предпросмотром; тип показан read-only.
- [x] 4.3 Проверить деградацию/ошибки: невалидный ручной ввод показывает ошибку
в блоке и не меняет активный источник.
- [x] 4.4 `openspec validate review-unified-source-block --strict` — без ошибок.
+63 -34
View File
@@ -3,7 +3,8 @@
## Purpose
Ревью раскладки человеком после распознавания и матча: петля «догадка →
подсказка → перераспознавание», команды (Применить/Уточнить/Распознать заново/
Тип/Игнор/Позже/Отклонить/Undo/Привязать заново), мягкие подсказки vs жёсткие
Игнор/Позже/Отклонить/Undo/Привязать заново; тип — read-only, корректируется
через «Уточнить»), мягкие подсказки vs жёсткие
`override`, единый список источников совпадения с ручным добавлением и
предпросмотром (превью = применение), разделение труда транспортов (веб —
точные правки, Telegram — быстрые действия и эскалация в веб).
@@ -28,13 +29,16 @@ LLM; нет матча в базе или несколько кандидато
Экран ревью SHALL предоставлять команды: **Применить** (создать хардлинки по
эффективному плану), **Уточнить** (добавить подсказку → перераспознать),
**Распознать заново** (повторный прогон без новой подсказки), **Тип** (переключить
movie↔series), **Игнор файла**, **Позже** (`deferred`), **Отклонить**
(`cancelled`), **Undo** (снять созданные ссылки → `reverted`) и **Привязать
заново** (из `reverted`/`cancelled`/`target_missing` → перераспознавание с ручным
подтверждением). Команды из любого транспорта SHALL сериализоваться worker'ом под
единой блокировкой; применяется последняя валидная команда. Команды,
которым нужен источник, SHALL проверять его наличие синхронно перед действием.
**Распознать заново** (повторный прогон без новой подсказки), **Игнор файла**,
**Позже** (`deferred`), **Отклонить** (`cancelled`), **Undo** (снять созданные
ссылки → `reverted`) и **Привязать заново** (из
`reverted`/`cancelled`/`target_missing` → перераспознавание с ручным
подтверждением). Экран ревью MUST NOT содержать команду переключения типа
movie↔series: тип показывается read-only, а его корректировка выполняется
мягкой подсказкой через **Уточнить**. Команды из любого транспорта SHALL
сериализоваться worker'ом под единой блокировкой; применяется последняя валидная
команда. Команды, которым нужен источник, SHALL проверять его наличие синхронно
перед действием.
#### Scenario: Применение создаёт раскладку
@@ -49,6 +53,13 @@ movie↔series), **Игнор файла**, **Позже** (`deferred`), **От
- **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным
подтверждением (авто-раскладка не делается)
#### Scenario: Тип не переключается кнопкой
- **GIVEN** загрузка в `review` с распознанным типом
- **WHEN** пользователь открывает экран ревью
- **THEN** отдельной команды/кнопки переключения movie↔series на экране нет
- **AND** тип показан read-only в инфо-части выбранного источника
### Requirement: Подсказка мягкая, override жёсткий
Подсказка (`hint`) SHALL быть мягким сигналом — её интерпретирует LLM при
@@ -69,11 +80,15 @@ movie↔series), **Игнор файла**, **Позже** (`deferred`), **От
списком**, в котором распознавание нейронкой (без базы) — такая же строка,
как кандидаты метабаз (TMDB/TVDB/TVMaze), а не отдельный режим сверху.
Ровно один источник в списке SHALL быть отмечен активным (эффективный
матч). Экран SHALL позволять как операции над этим списком: выбрать
кандидата базы, переключиться на другого кандидата и снять матч с базой
обратно на нейронку («без базы»). Смена активного источника SHALL
выполняться через раундтрип на сервер (форма/htmx), без клиентского
пересчёта доменного состояния. Список источников SHALL показываться только
матч). Активный источник SHALL выбираться **одним кликом/тапом по строке
варианта** (радиокнопка), без отдельной кнопки подтверждения выбора. Выбор
источника SHALL сохранять его как эффективный матч (persist) и SHALL
выполняться через раундтрип на сервер (форма/htmx), без клиентского пересчёта
доменного состояния; при этом инфо-часть и предпросмотр раскладки SHALL
немедленно обновляться под выбранный источник (частичный своп блока, без полной
перезагрузки страницы). Экран SHALL позволять операции над этим списком:
выбрать кандидата базы, переключиться на другого кандидата и снять матч с базы
обратно на нейронку («без базы»). Список источников SHALL показываться только
при наличии плана распознавания.
#### Scenario: Нейронка — строка в общем списке
@@ -85,16 +100,19 @@ movie↔series), **Игнор файла**, **Позже** (`deferred`), **От
нейронкой» стоит наравне с кандидатами баз
- **AND** активным отмечен ровно один источник (текущий эффективный матч)
#### Scenario: Переключение между кандидатами
#### Scenario: Выбор кандидата одним кликом
- **GIVEN** на экране ревью выбран один кандидат метабазы
- **WHEN** пользователь выбирает другого кандидата из списка
- **THEN** активным становится выбранный кандидат, прочие — неактивны
- **WHEN** пользователь кликает/тапает строку другого кандидата
- **THEN** выбранный кандидат сохраняется активным, прочие — неактивны, без
отдельного нажатия кнопки «выбрать»
- **AND** инфо-часть и предпросмотр раскладки сразу обновляются под выбранного
кандидата без полной перезагрузки страницы
#### Scenario: Снятие матча в пользу нейронки
- **GIVEN** на экране ревью активен кандидат метабазы с названием «Fargo»
- **WHEN** пользователь выбирает строку «распознано нейронкой»
- **WHEN** пользователь кликает строку «распознано нейронкой»
- **THEN** матч с базой снимается (источник — нейронка, «без базы»), тег
папки провайдера не проставляется
- **AND** поля источника — из распознавания нейронкой, без унаследованных
@@ -132,30 +150,41 @@ movie↔series), **Игнор файла**, **Позже** (`deferred`), **От
- **THEN** экран показывает сообщение об ошибке и не меняет текущий активный
источник
### Requirement: Предпросмотр полей источника до фиксации выбора
### Requirement: Инфо и предпросмотр выбранного источника
Экран ревью SHALL показывать для рассматриваемого источника (нейронка,
кандидат базы или добавленный вручную) **поля** результата — тип, название,
год, с зарезервированным местом под режиссёра. Показ полей источника
MUST NOT менять сохранённый матч загрузки и MUST NOT создавать хардлинки:
сохранённый матч меняется только явным выбором источника, а раскладка —
только действием «Применить». Совпадение целевых путей предпросмотра с
результатом применения регулируется требованием «Превью раскладки через
единую логику именования» (`web-ui`).
В едином блоке выбора источника экран ревью SHALL показывать для **выбранного
(активного)** источника две части: **инфо** — тип (read-only, movie/series),
название, оригинальное название, год, для сериала — сводку сезонов (один сезон,
диапазон/список для многосезонного пака или «Спецвыпуски»), с
зарезервированным местом под режиссёра; и **предпросмотр раскладки** — целевые
пути хардлинков этого источника. Обе части SHALL относиться именно к активному
источнику и SHALL обновляться при смене выбора. Отрисовка блока (показ инфо и
предпросмотра) MUST NOT создавать хардлинки: раскладка создаётся только явным
действием «Применить». Совпадение целевых путей предпросмотра с результатом
применения регулируется требованием «Превью раскладки через единую логику
именования» (`web-ui`).
#### Scenario: Предпросмотр полей без фиксации выбора
#### Scenario: Инфо и предпросмотр относятся к активному источнику
- **GIVEN** список источников на экране ревью
- **WHEN** пользователь рассматривает источник, ещё не выбрав его активным
- **THEN** показаны поля результата (тип, название, год) для этого источника
- **AND** сохранённый матч загрузки не меняется, хардлинки не создаются
- **GIVEN** в списке активен кандидат метабазы
- **WHEN** пользователь смотрит инфо-часть и предпросмотр раскладки
- **THEN** показаны тип, название, ориг. название, год (и сводка сезонов для
сериала) именно этого источника и предпросмотр его целевых путей
#### Scenario: Просмотр блока не создаёт раскладку
- **GIVEN** экран ревью с показанным блоком выбора источника
- **WHEN** пользователь только просматривает инфо и предпросмотр, не нажимая
«Применить»
- **THEN** хардлинки не создаются, файлы под `paths.movies`/`series` не
меняются
#### Scenario: Зарезервированное место под режиссёра
- **GIVEN** режиссёр из метабазы пока не загружается
- **WHEN** отображается предпросмотр полей источника
- **THEN** в предпросмотре присутствует место под режиссёра, показанное
пустым (или прочерком), не ломая вёрстку
- **WHEN** отображается инфо-часть выбранного источника
- **THEN** в ней присутствует место под режиссёра, показанное пустым (или
прочерком), не ломая вёрстку
### Requirement: Разделение труда транспортов в ревью
+17 -8
View File
@@ -230,17 +230,26 @@ jellybit (`created_at`). Порядок MUST быть согласован ме
Превью целевых путей раскладки в веб-UI SHALL вычисляться той же логикой
именования, что и реальная раскладка (`internal/naming`/`internal/layout`), а
не дублировать правила в шаблоне. На экране ревью превью SHALL строиться **для
каждого источника в списке** (нейронка, кандидат базы, добавленный вручную) —
эфемерно на сервере, без записи сохранённого матча. Показанные для источника
пути MUST совпадать с теми, что создались бы при выборе этого источника и
применении.
выбранного (активного) источника** — эфемерно на сервере, без записи
сохранённого матча самим показом. При смене выбранного источника превью SHALL
пересчитываться под него и обновляться частичным свопом блока. Показанные для
источника пути MUST совпадать с теми, что создались бы при применении этого
источника.
#### Scenario: Превью совпадает с реальной раскладкой
- **WHEN** на экране ревью отображается превью целевых путей для источника
- **THEN** эти пути идентичны тем, что создаст применение при выборе этого
источника (те же правила имён, спецвыпусков, мультифайла, запрещённых
символов, тега провайдера и коллизий)
- **WHEN** на экране ревью отображается превью целевых путей для выбранного
источника
- **THEN** эти пути идентичны тем, что создаст применение этого источника (те же
правила имён, спецвыпусков, мультифайла, запрещённых символов, тега провайдера
и коллизий)
#### Scenario: Смена источника пересчитывает превью
- **GIVEN** на экране ревью показан предпросмотр раскладки активного источника
- **WHEN** пользователь выбирает другой источник в списке
- **THEN** превью пересчитывается под выбранный источник и обновляется без
полной перезагрузки страницы
#### Scenario: Переключение источника не тянет чужие поля
+18 -1
View File
@@ -310,8 +310,15 @@ details.spoiler{margin-top:var(--sp-3)}
.cand-list{display:flex;flex-direction:column;gap:var(--sp-2)}
.cand{display:flex;align-items:center;gap:var(--sp-3);padding:11px var(--sp-4);
border:1px solid var(--border-strong);border-radius:var(--r-md);background:var(--surface);
cursor:pointer;transition:border-color .12s,background .12s}
transition:border-color .12s,background .12s}
.cand:hover{background:var(--surface-2)}
/* Вся строка кликабельна: label оборачивает радио и текст, крупная тап-зона на
телефоне. Внешняя ссылка «запись ↗» вынесена из label и выбор не переключает. */
.cand-pick{display:flex;align-items:center;gap:var(--sp-3);flex:1;min-width:0;cursor:pointer}
.cand-radio{position:absolute;width:1px;height:1px;opacity:0}
.cand-radio:focus-visible + .radio{outline:2px solid var(--accent);outline-offset:2px}
.cand-radio:checked + .radio{border-color:var(--accent)}
.cand-radio:checked + .radio::after{content:"";width:9px;height:9px;border-radius:50%;background:var(--accent)}
.cand.selected{border-color:var(--accent);background:var(--accent-weak);
box-shadow:0 0 0 1px var(--accent) inset}
.cand .radio{width:18px;height:18px;border-radius:50%;border:2px solid var(--border-strong);
@@ -334,6 +341,16 @@ details.spoiler{margin-top:var(--sp-3)}
.cand-extra{display:flex;gap:var(--sp-2);margin-top:var(--sp-3);flex-wrap:wrap;align-items:center}
.cand-extra .input{flex:1;min-width:120px}
/* единый блок источника: ошибка выбора, инфо о выбранном, предпросмотр */
.block-error{background:var(--st-err-bg);
border:1px solid color-mix(in srgb,var(--st-err) 35%,transparent);color:var(--st-err);
padding:9px 12px;border-radius:var(--r-sm);font-size:var(--fs-sm);margin-bottom:var(--sp-3)}
.src-info{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));
gap:var(--sp-3);margin-top:var(--sp-4);padding-top:var(--sp-4);
border-top:1px solid var(--border)}
.src-layout{margin-top:var(--sp-4)}
.sub-head{font-size:var(--fs-sm);font-weight:600;margin:0;letter-spacing:-.005em}
/* ---------- 12. Таблицы ---------- */
.table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:var(--r-md)}
table.tbl{width:100%;border-collapse:collapse;font-size:var(--fs-sm);min-width:560px}
@@ -0,0 +1,74 @@
{{define "review_source_block"}}
<div class="section" id="source-block">
<div class="section-head">
<h2>Источник и раскладка</h2>
<span class="hint">выбери вариант — инфо и раскладка обновятся сразу</span>
</div>
{{if .BlockError}}
<div class="block-error">{{.BlockError}}</div>
{{end}}
<!-- 1. Список вариантов: клик по строке = выбор + сохранение (htmx) -->
<div class="cand-list">
{{range .Sources}}
<div class="cand{{if .Active}} selected{{end}}{{if eq .Kind "neural"}} nn-cand{{end}}">
<label class="cand-pick">
<input class="cand-radio" type="radio" name="candidate_id"
value="{{if eq .Kind "neural"}}{{else}}{{.CandidateID}}{{end}}"
{{if .Active}}checked{{end}}
hx-post="/ui/downloads/{{$.ID}}/{{if eq .Kind "neural"}}nobase{{else}}candidate{{end}}"
hx-trigger="change"
hx-target="#source-block"
hx-swap="outerHTML">
<span class="radio"></span>
<div class="body">
{{if eq .Kind "neural"}}
<div class="name"><span class="prov nn">нейронка</span> распознано нейронкой</div>
<div class="sub">без базы — тег папки не ставится</div>
{{else}}
<div class="name"><span class="prov {{.Provider}}">{{.Provider}}</span> {{if .Title}}{{.Title}}{{else}}<span class="faint">id {{.ProviderID}}</span>{{end}}{{if .Year}} <span class="muted">· {{.Year}}</span>{{end}}</div>
<div class="sub">id {{.ProviderID}}</div>
{{end}}
</div>
</label>
{{if .MatchURL}}<a class="ext-link" href="{{.MatchURL}}" target="_blank" rel="noopener">запись ↗</a>{{end}}
</div>
{{end}}
</div>
<!-- Ручное добавление источника -->
<form class="cand-extra" hx-post="/ui/downloads/{{.ID}}/source"
hx-target="#source-block" hx-swap="outerHTML">
<select class="select" name="provider" style="max-width:120px">
<option value="tmdb">TMDB</option>
<option value="tvdb">TVDB</option>
<option value="imdb">IMDb</option>
</select>
<input class="input mono" name="provider_id" placeholder="id или URL записи (для TVDB — числовой id)" required>
<button class="btn btn-sm" type="submit">Добавить</button>
</form>
<!-- 2. Инфо о выбранном источнике (тип read-only) -->
<div class="src-info">
<div class="field"><span class="label">Тип</span><div>{{if .IsSeries}}сериал{{else}}фильм{{end}}</div></div>
<div class="field"><span class="label">Название</span><div>{{.Title}}</div></div>
{{if .OriginalTitle}}<div class="field"><span class="label">Ориг. название</span><div class="mono">{{.OriginalTitle}}</div></div>{{end}}
{{if .Year}}<div class="field"><span class="label">Год</span><div>{{.Year}}</div></div>{{end}}
{{if .IsSeries}}<div class="field"><span class="label">Сезоны</span><div>{{if .SeasonSummary}}{{.SeasonSummary}}{{else}}<span class="faint"></span>{{end}}</div></div>{{end}}
<div class="field"><span class="label">Режиссёр</span><div><span class="faint"></span></div></div>
</div>
<!-- 3. Предпросмотр раскладки выбранного источника -->
<div class="src-layout">
<div class="section-head" style="margin-bottom:var(--sp-3)">
<h3 class="sub-head">Раскладка</h3>
<span class="hint">файл источника → целевой хардлинк</span>
</div>
{{template "layout_widget" .Files}}
<p class="faint" style="font-size:var(--fs-xs);margin:var(--sp-3) 0 0">
Буквальные целевые пути. Применяются как хардлинки — исходник остаётся на раздаче.
</p>
</div>
</div>
{{end}}
+2 -80
View File
@@ -46,86 +46,8 @@
{{end}}
{{if .HasPlan}}
<!-- Догадка -->
<div class="section">
<div class="section-head"><h2>Догадка</h2></div>
<div style="margin-bottom:var(--sp-4)">
<span class="label">Тип</span>
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type" style="display:flex;gap:var(--sp-2);align-items:center">
<button class="btn btn-sm{{if not .IsSeries}} btn-primary{{end}}" name="type" value="movie"{{if not .IsSeries}} disabled{{end}}>фильм</button>
<button class="btn btn-sm{{if .IsSeries}} btn-primary{{end}}" name="type" value="series"{{if .IsSeries}} disabled{{end}}>сериал</button>
<span class="hint">переключение пересоберёт план</span>
</form>
</div>
<div class="guess-grid">
<div class="field"><span class="label">Название</span><div>{{.Title}}</div></div>
{{if .OriginalTitle}}<div class="field"><span class="label">Ориг. название</span><div class="mono">{{.OriginalTitle}}</div></div>{{end}}
{{if .Year}}<div class="field"><span class="label">Год</span><div>{{.Year}}</div></div>{{end}}
</div>
</div>
<!-- Источник совпадения -->
<div class="section">
<div class="section-head"><h2>Источник совпадения</h2><span class="hint">выбери источник — распознавание нейронкой или запись базы</span></div>
<div class="cand-list">
{{range .Sources}}
<div class="cand{{if .Active}} selected{{end}}">
<span class="radio"></span>
<div class="body">
{{if eq .Kind "neural"}}
<div class="name"><span class="prov none">нейронка</span> распознано нейронкой</div>
<div class="sub">без базы — тег папки не ставится</div>
{{else}}
<div class="name"><span class="prov {{.Provider}}">{{.Provider}}</span> {{if .Title}}{{.Title}}{{else}}<span class="faint">id {{.ProviderID}}</span>{{end}}{{if .Year}} <span class="muted">· {{.Year}}</span>{{end}}</div>
<div class="sub">id {{.ProviderID}}{{if .MatchURL}} · <a class="ext-link" href="{{.MatchURL}}" target="_blank" rel="noopener">запись ↗</a>{{end}}</div>
{{end}}
{{if not .Active}}
<details class="src-preview">
<summary>предпросмотр</summary>
<div class="src-fields">
<span class="label">Тип</span> {{if .IsSeries}}сериал{{else}}фильм{{end}} ·
<span class="label">Название</span> {{.Title}}{{if .Year}} ({{.Year}}){{end}} ·
<span class="label">Режиссёр</span> <span class="faint"></span>
</div>
{{template "layout_widget" .Files}}
</details>
{{end}}
</div>
{{if .Active}}
<button class="btn btn-sm" type="button" disabled>активен</button>
{{else}}
<form method="post" action="/ui/downloads/{{$.ID}}/{{if eq .Kind "neural"}}nobase{{else}}candidate{{end}}">
{{if ne .Kind "neural"}}<input type="hidden" name="candidate_id" value="{{.CandidateID}}">{{end}}
<button class="btn btn-sm btn-primary" type="submit">выбрать</button>
</form>
{{end}}
</div>
{{end}}
</div>
<form class="cand-extra" method="post" action="/ui/downloads/{{.ID}}/source">
<select class="select" name="provider" style="max-width:120px">
<option value="tmdb">TMDB</option>
<option value="tvdb">TVDB</option>
<option value="imdb">IMDb</option>
</select>
<input class="input mono" name="provider_id" placeholder="id или URL записи (для TVDB — числовой id)" required>
<button class="btn btn-sm" type="submit">Добавить</button>
</form>
</div>
<!-- Раскладка -->
<div class="section">
<div class="section-head">
<h2>Раскладка</h2>
<span class="hint">файл источника → целевой хардлинк</span>
</div>
{{template "layout_widget" .Files}}
<p class="faint" style="font-size:var(--fs-xs);margin:var(--sp-3) 0 0">
Буквальные целевые пути. Применяются как хардлинки — исходник остаётся на раздаче.
</p>
</div>
<!-- Единый блок: выбор источника → инфо → предпросмотр раскладки -->
{{template "review_source_block" .}}
{{end}}
<!-- Уточнить и перераспознать -->