Ревью: единый блок выбора источника, клик = выбор (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:
@@ -236,6 +236,26 @@ Web-сторона реализована: страница загрузки `/d
|
|||||||
Связано: [review-ux.md](specs/review-ux.md), [recognition.md](specs/recognition.md)
|
Связано: [review-ux.md](specs/review-ux.md), [recognition.md](specs/recognition.md)
|
||||||
(матч в базе), [architecture.md](specs/architecture.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`) без сезонов, а
|
Релизы аниме часто нумеруют серии сквозным числом (`#137`) без сезонов, а
|
||||||
@@ -368,6 +388,19 @@ URL кандидата хардкодит `.../dereferrer/series/{id}` (там
|
|||||||
`openspec/specs/metadata-match` (требование «Кандидат несёт URL»), пакеты
|
`openspec/specs/metadata-match` (требование «Кандидат несёт URL»), пакеты
|
||||||
`metadata`, `httpapi`.
|
`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
|
### Мгновенные обновления через SSE
|
||||||
|
|
||||||
Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто
|
Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
)
|
)
|
||||||
@@ -36,6 +40,69 @@ func buildFileRows(plan recognize.Plan, preview []layout.Link) []fileRow {
|
|||||||
return rows
|
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] → «1–3», [1,3,4] → «1, 3–4».
|
||||||
|
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 — человекочитаемая роль файла раскладки.
|
// roleLabel — человекочитаемая роль файла раскладки.
|
||||||
func roleLabel(role string) string {
|
func roleLabel(role string) string {
|
||||||
switch role {
|
switch role {
|
||||||
|
|||||||
@@ -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))}, "Сезоны 1–3"},
|
||||||
|
{"разрыв", []recognize.PlanFile{ep(n(1)), ep(n(3)), ep(n(4))}, "Сезоны 1, 3–4"},
|
||||||
|
{"несортированный вход", []recognize.PlanFile{ep(n(3)), ep(n(1)), ep(n(2))}, "Сезоны 1–3"},
|
||||||
|
{"только спецвыпуски (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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,7 +114,6 @@ func NewRouter(d Deps) (http.Handler, error) {
|
|||||||
r.Post("/ui/downloads/{id}/apply", s.handleApply)
|
r.Post("/ui/downloads/{id}/apply", s.handleApply)
|
||||||
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
|
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
|
||||||
r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize)
|
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}/ignore", s.handleIgnore)
|
||||||
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
|
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
|
||||||
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
|
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -395,7 +396,6 @@ type fakeReviewer struct {
|
|||||||
data *worker.ReviewData
|
data *worker.ReviewData
|
||||||
applyErr error
|
applyErr error
|
||||||
refined map[string]string
|
refined map[string]string
|
||||||
typed map[string]string
|
|
||||||
ignored map[string]string
|
ignored map[string]string
|
||||||
chosen map[string]string
|
chosen map[string]string
|
||||||
providerSet 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
|
f.refined[id] = hint
|
||||||
return nil
|
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 {
|
func (f *fakeReviewer) IgnoreFile(_ context.Context, id string, src string) error {
|
||||||
if f.ignored == nil {
|
if f.ignored == nil {
|
||||||
f.ignored = map[string]string{}
|
f.ignored = map[string]string{}
|
||||||
@@ -541,11 +534,16 @@ func TestReviewRenders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
|
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
|
||||||
"Season 02", "Применить", "Уточнить",
|
"Season 02", "Применить", "Уточнить",
|
||||||
"Источник совпадения", "269613", "выбрать", "распознано нейронкой", "Добавить"} {
|
"Источник и раскладка", "269613", "распознано нейронкой", "Добавить",
|
||||||
|
"Сезон 2"} {
|
||||||
if !strings.Contains(string(body), want) {
|
if !strings.Contains(string(body), want) {
|
||||||
t.Errorf("страница ревью не содержит %q", want)
|
t.Errorf("страница ревью не содержит %q", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Кнопки «выбрать» больше нет — выбор одним кликом по радио.
|
||||||
|
if strings.Contains(string(body), ">выбрать<") {
|
||||||
|
t.Error("страница ревью всё ещё содержит кнопку «выбрать»")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReviewShowsMatchLink(t *testing.T) {
|
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) {
|
func TestApplyRedirectsToIndex(t *testing.T) {
|
||||||
rv := &fakeReviewer{data: seriesReviewData()}
|
rv := &fakeReviewer{data: seriesReviewData()}
|
||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
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()}
|
rv := &fakeReviewer{data: seriesReviewData()}
|
||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
Reader: &fakeReader{}, Reviewer: rv})
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
@@ -721,13 +758,40 @@ func TestIgnoreAndType(t *testing.T) {
|
|||||||
if rv.ignored[tid] != "Fargo/sample.mkv" {
|
if rv.ignored[tid] != "Fargo/sample.mkv" {
|
||||||
t.Errorf("IgnoreFile получил %q", rv.ignored[tid])
|
t.Errorf("IgnoreFile получил %q", rv.ignored[tid])
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/type",
|
// TestChooseCandidateHTMX: на htmx-запрос выбор возвращает партиал блока
|
||||||
map[string][]string{"type": {"movie"}}); err != nil {
|
// источника (а не полную страницу и не редирект), обновлённый под выбор.
|
||||||
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if rv.typed[tid] != "movie" {
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
t.Errorf("SetType получил %q", rv.typed[tid])
|
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
@@ -18,7 +18,6 @@ type Reviewer interface {
|
|||||||
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
|
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
|
||||||
Apply(ctx context.Context, id string) error
|
Apply(ctx context.Context, id string) error
|
||||||
Refine(ctx context.Context, id string, hint 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
|
IgnoreFile(ctx context.Context, id string, src string) error
|
||||||
Defer(ctx context.Context, id string) error
|
Defer(ctx context.Context, id string) error
|
||||||
Undo(ctx context.Context, id string) error
|
Undo(ctx context.Context, id string) error
|
||||||
@@ -44,6 +43,7 @@ type reviewView struct {
|
|||||||
Title string
|
Title string
|
||||||
OriginalTitle string
|
OriginalTitle string
|
||||||
Year int
|
Year int
|
||||||
|
SeasonSummary string // сводка сезонов для сериала (пусто для фильма)
|
||||||
Provider string
|
Provider string
|
||||||
ProviderID string
|
ProviderID string
|
||||||
MatchURL string // ссылка на подтверждённую запись метабазы (пусто — текстом)
|
MatchURL string // ссылка на подтверждённую запись метабазы (пусто — текстом)
|
||||||
@@ -55,10 +55,13 @@ type reviewView struct {
|
|||||||
HasLinks bool // есть хотя бы один целевой путь → можно применять
|
HasLinks bool // есть хотя бы один целевой путь → можно применять
|
||||||
NoBase bool // выбрано «без базы»
|
NoBase bool // выбрано «без базы»
|
||||||
Sources []sourceView // единый список источников совпадения
|
Sources []sourceView // единый список источников совпадения
|
||||||
|
BlockError string // ошибка выбора внутри блока (htmx); не путать с Error (?err=)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceView — строка единого списка источников на экране ревью: нейронка или
|
// sourceView — строка единого списка источников на экране ревью: нейронка или
|
||||||
// кандидат базы, с эффективными полями и предпросмотром целевых путей.
|
// кандидат базы. Инфо и предпросмотр раскладки показываются для активного
|
||||||
|
// источника из верхнеуровневых полей reviewView, поэтому per-source превью
|
||||||
|
// строка не несёт.
|
||||||
type sourceView struct {
|
type sourceView struct {
|
||||||
Kind string // "neural" | "candidate"
|
Kind string // "neural" | "candidate"
|
||||||
CandidateID string
|
CandidateID string
|
||||||
@@ -66,10 +69,8 @@ type sourceView struct {
|
|||||||
ProviderID string
|
ProviderID string
|
||||||
Title string
|
Title string
|
||||||
Year int
|
Year int
|
||||||
IsSeries bool
|
|
||||||
MatchURL string
|
MatchURL string
|
||||||
Active bool
|
Active bool
|
||||||
Files []fileRow // предпросмотр «файл → раскладка» этого источника
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
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
|
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{
|
view := reviewView{
|
||||||
ID: id,
|
ID: id,
|
||||||
Source: shorten(rd.Download.SourceRef, 80),
|
Source: shorten(rd.Download.SourceRef, 80),
|
||||||
Context: rd.Download.Context,
|
Context: rd.Download.Context,
|
||||||
State: string(rd.Download.State),
|
State: string(rd.Download.State),
|
||||||
Error: r.URL.Query().Get("err"),
|
Error: errMsg,
|
||||||
StateError: rd.Download.ErrorMsg.String,
|
StateError: rd.Download.ErrorMsg.String,
|
||||||
Hints: rd.Hints,
|
Hints: rd.Hints,
|
||||||
}
|
}
|
||||||
@@ -105,6 +114,9 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
view.Title = rd.Plan.Title
|
view.Title = rd.Plan.Title
|
||||||
view.OriginalTitle = rd.Plan.OriginalTitle
|
view.OriginalTitle = rd.Plan.OriginalTitle
|
||||||
view.Year = rd.Plan.Year
|
view.Year = rd.Plan.Year
|
||||||
|
if view.IsSeries {
|
||||||
|
view.SeasonSummary = seasonSummary(rd.Plan)
|
||||||
|
}
|
||||||
view.Reasons = rec.ReasonList()
|
view.Reasons = rec.ReasonList()
|
||||||
switch rd.Provider {
|
switch rd.Provider {
|
||||||
case "", "none":
|
case "", "none":
|
||||||
@@ -128,9 +140,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
ProviderID: src.ProviderID,
|
ProviderID: src.ProviderID,
|
||||||
Title: src.Title,
|
Title: src.Title,
|
||||||
Year: src.Year,
|
Year: src.Year,
|
||||||
IsSeries: src.Type == "series",
|
|
||||||
Active: src.Active,
|
Active: src.Active,
|
||||||
Files: buildFileRows(src.Plan, src.Preview),
|
|
||||||
}
|
}
|
||||||
if src.Kind == worker.SourceCandidate {
|
if src.Kind == worker.SourceCandidate {
|
||||||
sv.MatchURL = sourceMatchURL(src)
|
sv.MatchURL = sourceMatchURL(src)
|
||||||
@@ -138,8 +148,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
view.Sources = append(view.Sources, sv)
|
view.Sources = append(view.Sources, sv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return view
|
||||||
s.render(w, "review.html", view)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Действия ревью (POST → redirect) ---
|
// --- Действия ревью (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) {
|
func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) {
|
||||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||||
_ = r.ParseForm()
|
_ = 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) {
|
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()
|
_ = r.ParseForm()
|
||||||
// Входная граница: id кандидата из формы валидируется как ULID.
|
// Входная граница: id кандидата из формы валидируется как ULID.
|
||||||
candidateID, err := ident.Parse(r.PostForm.Get("candidate_id"))
|
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) {
|
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)
|
return s.deps.Reviewer.ClearProvider(ctx, id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -214,7 +216,7 @@ func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) {
|
|||||||
// handleAddSource добавляет источник вручную по id или URL записи метабазы и
|
// handleAddSource добавляет источник вручную по id или URL записи метабазы и
|
||||||
// выбирает его. Разбор ввода — на входной границе транспорта.
|
// выбирает его. Разбор ввода — на входной границе транспорта.
|
||||||
func (s *server) handleAddSource(w http.ResponseWriter, r *http.Request) {
|
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()
|
_ = r.ParseForm()
|
||||||
provider, providerID, err := parseManualSource(r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
|
provider, providerID, err := parseManualSource(r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -364,6 +366,48 @@ func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(co
|
|||||||
redirectReview(w, r, id, "")
|
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
|
// matchURL выбирает ссылку на подтверждённую запись метабазы. Приоритет — URL
|
||||||
// выбранного кандидата, но только если его provider+id совпадают с эффективными
|
// выбранного кандидата, но только если его provider+id совпадают с эффективными
|
||||||
// (человек мог выбрать кандидата, затем вручную переопределить 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` — без ошибок.
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
Ревью раскладки человеком после распознавания и матча: петля «догадка →
|
Ревью раскладки человеком после распознавания и матча: петля «догадка →
|
||||||
подсказка → перераспознавание», команды (Применить/Уточнить/Распознать заново/
|
подсказка → перераспознавание», команды (Применить/Уточнить/Распознать заново/
|
||||||
Тип/Игнор/Позже/Отклонить/Undo/Привязать заново), мягкие подсказки vs жёсткие
|
Игнор/Позже/Отклонить/Undo/Привязать заново; тип — read-only, корректируется
|
||||||
|
через «Уточнить»), мягкие подсказки vs жёсткие
|
||||||
`override`, единый список источников совпадения с ручным добавлением и
|
`override`, единый список источников совпадения с ручным добавлением и
|
||||||
предпросмотром (превью = применение), разделение труда транспортов (веб —
|
предпросмотром (превью = применение), разделение труда транспортов (веб —
|
||||||
точные правки, Telegram — быстрые действия и эскалация в веб).
|
точные правки, Telegram — быстрые действия и эскалация в веб).
|
||||||
@@ -28,13 +29,16 @@ LLM; нет матча в базе или несколько кандидато
|
|||||||
|
|
||||||
Экран ревью SHALL предоставлять команды: **Применить** (создать хардлинки по
|
Экран ревью SHALL предоставлять команды: **Применить** (создать хардлинки по
|
||||||
эффективному плану), **Уточнить** (добавить подсказку → перераспознать),
|
эффективному плану), **Уточнить** (добавить подсказку → перераспознать),
|
||||||
**Распознать заново** (повторный прогон без новой подсказки), **Тип** (переключить
|
**Распознать заново** (повторный прогон без новой подсказки), **Игнор файла**,
|
||||||
movie↔series), **Игнор файла**, **Позже** (`deferred`), **Отклонить**
|
**Позже** (`deferred`), **Отклонить** (`cancelled`), **Undo** (снять созданные
|
||||||
(`cancelled`), **Undo** (снять созданные ссылки → `reverted`) и **Привязать
|
ссылки → `reverted`) и **Привязать заново** (из
|
||||||
заново** (из `reverted`/`cancelled`/`target_missing` → перераспознавание с ручным
|
`reverted`/`cancelled`/`target_missing` → перераспознавание с ручным
|
||||||
подтверждением). Команды из любого транспорта SHALL сериализоваться worker'ом под
|
подтверждением). Экран ревью MUST NOT содержать команду переключения типа
|
||||||
единой блокировкой; применяется последняя валидная команда. Команды,
|
movie↔series: тип показывается read-only, а его корректировка выполняется
|
||||||
которым нужен источник, SHALL проверять его наличие синхронно перед действием.
|
мягкой подсказкой через **Уточнить**. Команды из любого транспорта SHALL
|
||||||
|
сериализоваться worker'ом под единой блокировкой; применяется последняя валидная
|
||||||
|
команда. Команды, которым нужен источник, SHALL проверять его наличие синхронно
|
||||||
|
перед действием.
|
||||||
|
|
||||||
#### Scenario: Применение создаёт раскладку
|
#### Scenario: Применение создаёт раскладку
|
||||||
|
|
||||||
@@ -49,6 +53,13 @@ movie↔series), **Игнор файла**, **Позже** (`deferred`), **От
|
|||||||
- **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным
|
- **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным
|
||||||
подтверждением (авто-раскладка не делается)
|
подтверждением (авто-раскладка не делается)
|
||||||
|
|
||||||
|
#### Scenario: Тип не переключается кнопкой
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review` с распознанным типом
|
||||||
|
- **WHEN** пользователь открывает экран ревью
|
||||||
|
- **THEN** отдельной команды/кнопки переключения movie↔series на экране нет
|
||||||
|
- **AND** тип показан read-only в инфо-части выбранного источника
|
||||||
|
|
||||||
### Requirement: Подсказка мягкая, override жёсткий
|
### Requirement: Подсказка мягкая, override жёсткий
|
||||||
|
|
||||||
Подсказка (`hint`) SHALL быть мягким сигналом — её интерпретирует LLM при
|
Подсказка (`hint`) SHALL быть мягким сигналом — её интерпретирует LLM при
|
||||||
@@ -69,11 +80,15 @@ movie↔series), **Игнор файла**, **Позже** (`deferred`), **От
|
|||||||
списком**, в котором распознавание нейронкой (без базы) — такая же строка,
|
списком**, в котором распознавание нейронкой (без базы) — такая же строка,
|
||||||
как кандидаты метабаз (TMDB/TVDB/TVMaze), а не отдельный режим сверху.
|
как кандидаты метабаз (TMDB/TVDB/TVMaze), а не отдельный режим сверху.
|
||||||
Ровно один источник в списке SHALL быть отмечен активным (эффективный
|
Ровно один источник в списке SHALL быть отмечен активным (эффективный
|
||||||
матч). Экран SHALL позволять как операции над этим списком: выбрать
|
матч). Активный источник SHALL выбираться **одним кликом/тапом по строке
|
||||||
кандидата базы, переключиться на другого кандидата и снять матч с базой
|
варианта** (радиокнопка), без отдельной кнопки подтверждения выбора. Выбор
|
||||||
обратно на нейронку («без базы»). Смена активного источника SHALL
|
источника SHALL сохранять его как эффективный матч (persist) и SHALL
|
||||||
выполняться через раундтрип на сервер (форма/htmx), без клиентского
|
выполняться через раундтрип на сервер (форма/htmx), без клиентского пересчёта
|
||||||
пересчёта доменного состояния. Список источников SHALL показываться только
|
доменного состояния; при этом инфо-часть и предпросмотр раскладки SHALL
|
||||||
|
немедленно обновляться под выбранный источник (частичный своп блока, без полной
|
||||||
|
перезагрузки страницы). Экран SHALL позволять операции над этим списком:
|
||||||
|
выбрать кандидата базы, переключиться на другого кандидата и снять матч с базы
|
||||||
|
обратно на нейронку («без базы»). Список источников SHALL показываться только
|
||||||
при наличии плана распознавания.
|
при наличии плана распознавания.
|
||||||
|
|
||||||
#### Scenario: Нейронка — строка в общем списке
|
#### Scenario: Нейронка — строка в общем списке
|
||||||
@@ -85,16 +100,19 @@ movie↔series), **Игнор файла**, **Позже** (`deferred`), **От
|
|||||||
нейронкой» стоит наравне с кандидатами баз
|
нейронкой» стоит наравне с кандидатами баз
|
||||||
- **AND** активным отмечен ровно один источник (текущий эффективный матч)
|
- **AND** активным отмечен ровно один источник (текущий эффективный матч)
|
||||||
|
|
||||||
#### Scenario: Переключение между кандидатами
|
#### Scenario: Выбор кандидата одним кликом
|
||||||
|
|
||||||
- **GIVEN** на экране ревью выбран один кандидат метабазы
|
- **GIVEN** на экране ревью выбран один кандидат метабазы
|
||||||
- **WHEN** пользователь выбирает другого кандидата из списка
|
- **WHEN** пользователь кликает/тапает строку другого кандидата
|
||||||
- **THEN** активным становится выбранный кандидат, прочие — неактивны
|
- **THEN** выбранный кандидат сохраняется активным, прочие — неактивны, без
|
||||||
|
отдельного нажатия кнопки «выбрать»
|
||||||
|
- **AND** инфо-часть и предпросмотр раскладки сразу обновляются под выбранного
|
||||||
|
кандидата без полной перезагрузки страницы
|
||||||
|
|
||||||
#### Scenario: Снятие матча в пользу нейронки
|
#### Scenario: Снятие матча в пользу нейронки
|
||||||
|
|
||||||
- **GIVEN** на экране ревью активен кандидат метабазы с названием «Fargo»
|
- **GIVEN** на экране ревью активен кандидат метабазы с названием «Fargo»
|
||||||
- **WHEN** пользователь выбирает строку «распознано нейронкой»
|
- **WHEN** пользователь кликает строку «распознано нейронкой»
|
||||||
- **THEN** матч с базой снимается (источник — нейронка, «без базы»), тег
|
- **THEN** матч с базой снимается (источник — нейронка, «без базы»), тег
|
||||||
папки провайдера не проставляется
|
папки провайдера не проставляется
|
||||||
- **AND** поля источника — из распознавания нейронкой, без унаследованных
|
- **AND** поля источника — из распознавания нейронкой, без унаследованных
|
||||||
@@ -132,30 +150,41 @@ movie↔series), **Игнор файла**, **Позже** (`deferred`), **От
|
|||||||
- **THEN** экран показывает сообщение об ошибке и не меняет текущий активный
|
- **THEN** экран показывает сообщение об ошибке и не меняет текущий активный
|
||||||
источник
|
источник
|
||||||
|
|
||||||
### Requirement: Предпросмотр полей источника до фиксации выбора
|
### Requirement: Инфо и предпросмотр выбранного источника
|
||||||
|
|
||||||
Экран ревью SHALL показывать для рассматриваемого источника (нейронка,
|
В едином блоке выбора источника экран ревью SHALL показывать для **выбранного
|
||||||
кандидат базы или добавленный вручную) **поля** результата — тип, название,
|
(активного)** источника две части: **инфо** — тип (read-only, movie/series),
|
||||||
год, с зарезервированным местом под режиссёра. Показ полей источника
|
название, оригинальное название, год, для сериала — сводку сезонов (один сезон,
|
||||||
MUST NOT менять сохранённый матч загрузки и MUST NOT создавать хардлинки:
|
диапазон/список для многосезонного пака или «Спецвыпуски»), с
|
||||||
сохранённый матч меняется только явным выбором источника, а раскладка —
|
зарезервированным местом под режиссёра; и **предпросмотр раскладки** — целевые
|
||||||
только действием «Применить». Совпадение целевых путей предпросмотра с
|
пути хардлинков этого источника. Обе части SHALL относиться именно к активному
|
||||||
результатом применения регулируется требованием «Превью раскладки через
|
источнику и SHALL обновляться при смене выбора. Отрисовка блока (показ инфо и
|
||||||
единую логику именования» (`web-ui`).
|
предпросмотра) MUST NOT создавать хардлинки: раскладка создаётся только явным
|
||||||
|
действием «Применить». Совпадение целевых путей предпросмотра с результатом
|
||||||
|
применения регулируется требованием «Превью раскладки через единую логику
|
||||||
|
именования» (`web-ui`).
|
||||||
|
|
||||||
#### Scenario: Предпросмотр полей без фиксации выбора
|
#### Scenario: Инфо и предпросмотр относятся к активному источнику
|
||||||
|
|
||||||
- **GIVEN** список источников на экране ревью
|
- **GIVEN** в списке активен кандидат метабазы
|
||||||
- **WHEN** пользователь рассматривает источник, ещё не выбрав его активным
|
- **WHEN** пользователь смотрит инфо-часть и предпросмотр раскладки
|
||||||
- **THEN** показаны поля результата (тип, название, год) для этого источника
|
- **THEN** показаны тип, название, ориг. название, год (и сводка сезонов для
|
||||||
- **AND** сохранённый матч загрузки не меняется, хардлинки не создаются
|
сериала) именно этого источника и предпросмотр его целевых путей
|
||||||
|
|
||||||
|
#### Scenario: Просмотр блока не создаёт раскладку
|
||||||
|
|
||||||
|
- **GIVEN** экран ревью с показанным блоком выбора источника
|
||||||
|
- **WHEN** пользователь только просматривает инфо и предпросмотр, не нажимая
|
||||||
|
«Применить»
|
||||||
|
- **THEN** хардлинки не создаются, файлы под `paths.movies`/`series` не
|
||||||
|
меняются
|
||||||
|
|
||||||
#### Scenario: Зарезервированное место под режиссёра
|
#### Scenario: Зарезервированное место под режиссёра
|
||||||
|
|
||||||
- **GIVEN** режиссёр из метабазы пока не загружается
|
- **GIVEN** режиссёр из метабазы пока не загружается
|
||||||
- **WHEN** отображается предпросмотр полей источника
|
- **WHEN** отображается инфо-часть выбранного источника
|
||||||
- **THEN** в предпросмотре присутствует место под режиссёра, показанное
|
- **THEN** в ней присутствует место под режиссёра, показанное пустым (или
|
||||||
пустым (или прочерком), не ломая вёрстку
|
прочерком), не ломая вёрстку
|
||||||
|
|
||||||
### Requirement: Разделение труда транспортов в ревью
|
### Requirement: Разделение труда транспортов в ревью
|
||||||
|
|
||||||
|
|||||||
@@ -230,17 +230,26 @@ jellybit (`created_at`). Порядок MUST быть согласован ме
|
|||||||
Превью целевых путей раскладки в веб-UI SHALL вычисляться той же логикой
|
Превью целевых путей раскладки в веб-UI SHALL вычисляться той же логикой
|
||||||
именования, что и реальная раскладка (`internal/naming`/`internal/layout`), а
|
именования, что и реальная раскладка (`internal/naming`/`internal/layout`), а
|
||||||
не дублировать правила в шаблоне. На экране ревью превью SHALL строиться **для
|
не дублировать правила в шаблоне. На экране ревью превью SHALL строиться **для
|
||||||
каждого источника в списке** (нейронка, кандидат базы, добавленный вручную) —
|
выбранного (активного) источника** — эфемерно на сервере, без записи
|
||||||
эфемерно на сервере, без записи сохранённого матча. Показанные для источника
|
сохранённого матча самим показом. При смене выбранного источника превью SHALL
|
||||||
пути MUST совпадать с теми, что создались бы при выборе этого источника и
|
пересчитываться под него и обновляться частичным свопом блока. Показанные для
|
||||||
применении.
|
источника пути MUST совпадать с теми, что создались бы при применении этого
|
||||||
|
источника.
|
||||||
|
|
||||||
#### Scenario: Превью совпадает с реальной раскладкой
|
#### Scenario: Превью совпадает с реальной раскладкой
|
||||||
|
|
||||||
- **WHEN** на экране ревью отображается превью целевых путей для источника
|
- **WHEN** на экране ревью отображается превью целевых путей для выбранного
|
||||||
- **THEN** эти пути идентичны тем, что создаст применение при выборе этого
|
источника
|
||||||
источника (те же правила имён, спецвыпусков, мультифайла, запрещённых
|
- **THEN** эти пути идентичны тем, что создаст применение этого источника (те же
|
||||||
символов, тега провайдера и коллизий)
|
правила имён, спецвыпусков, мультифайла, запрещённых символов, тега провайдера
|
||||||
|
и коллизий)
|
||||||
|
|
||||||
|
#### Scenario: Смена источника пересчитывает превью
|
||||||
|
|
||||||
|
- **GIVEN** на экране ревью показан предпросмотр раскладки активного источника
|
||||||
|
- **WHEN** пользователь выбирает другой источник в списке
|
||||||
|
- **THEN** превью пересчитывается под выбранный источник и обновляется без
|
||||||
|
полной перезагрузки страницы
|
||||||
|
|
||||||
#### Scenario: Переключение источника не тянет чужие поля
|
#### Scenario: Переключение источника не тянет чужие поля
|
||||||
|
|
||||||
|
|||||||
@@ -310,8 +310,15 @@ details.spoiler{margin-top:var(--sp-3)}
|
|||||||
.cand-list{display:flex;flex-direction:column;gap:var(--sp-2)}
|
.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);
|
.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);
|
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)}
|
.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);
|
.cand.selected{border-color:var(--accent);background:var(--accent-weak);
|
||||||
box-shadow:0 0 0 1px var(--accent) inset}
|
box-shadow:0 0 0 1px var(--accent) inset}
|
||||||
.cand .radio{width:18px;height:18px;border-radius:50%;border:2px solid var(--border-strong);
|
.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{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}
|
.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. Таблицы ---------- */
|
/* ---------- 12. Таблицы ---------- */
|
||||||
.table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:var(--r-md)}
|
.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}
|
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}}
|
||||||
@@ -46,86 +46,8 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .HasPlan}}
|
{{if .HasPlan}}
|
||||||
<!-- Догадка -->
|
<!-- Единый блок: выбор источника → инфо → предпросмотр раскладки -->
|
||||||
<div class="section">
|
{{template "review_source_block" .}}
|
||||||
<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>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- Уточнить и перераспознать -->
|
<!-- Уточнить и перераспознать -->
|
||||||
|
|||||||
Reference in New Issue
Block a user