Files
jellybit/internal/httpapi/review.go
T
avandClaude Opus 4.8 c0b5ab7295 UI/UX списка и карточки загрузки: серверные фильтр/поиск/пагинация, матч-ссылка, имя раздачи (web-ui-list-detail)
- Список: серверные фильтр по группе состояний, поиск и пагинация (GET
  f/q/page/all, по 25), сортировка по времени добавления в qBittorrent
  (added_on) с фолбеком на created_at и tie-break по id.
- Заголовок загрузки = имя раздачи (display_name) → распознанное название →
  усечённый источник; сырой magnet вынесен в блок «Информация о торренте».
- Матч метабазы показан ссылкой на запись (страница загрузки и ревью);
  URL берётся у выбранного кандидата либо строится по provider+id и типу.
- Полировка вёрстки; клиентская JS-фильтрация убрана (всё серверное, без JS).
- Миграция 0005 (display_name, source_added_at); воркер однократно
  фиксирует source_added_at при поллинге/усыновлении; ER-схема обновлена.
- OpenSpec: дельты влиты в specs/{web-ui,ingest}, change заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:50:24 +03:00

306 lines
10 KiB
Go

package httpapi
import (
"context"
"errors"
"net/http"
"net/url"
"strconv"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// Reviewer — операции ревью и раскладки (worker.Worker).
type Reviewer interface {
ReviewData(ctx context.Context, id int64) (*worker.ReviewData, error)
Apply(ctx context.Context, id int64) error
Refine(ctx context.Context, id int64, hint string) error
SetType(ctx context.Context, id int64, mediaType string) error
IgnoreFile(ctx context.Context, id int64, src string) error
Defer(ctx context.Context, id int64) error
Undo(ctx context.Context, id int64) error
Relink(ctx context.Context, id int64) error
Rerecognize(ctx context.Context, id int64) error
ChooseCandidate(ctx context.Context, id, candidateID int64) error
SetProviderID(ctx context.Context, id int64, provider, providerID string) error
ClearProvider(ctx context.Context, id int64) error
}
// --- Представление страницы ревью ---
type reviewView struct {
ID int64
Source string
Context string
State string
Error string // из ?err=
StateError string // error_msg загрузки (напр. причина коллизии)
MediaType string
IsSeries bool
Title string
OriginalTitle string
Year int
Provider string
ProviderID string
MatchURL string // ссылка на подтверждённую запись метабазы (пусто — текстом)
Confidence string
Reasons []string
Hints []string
Files []fileRow
HasPlan bool
HasLinks bool // есть хотя бы один целевой путь → можно применять
NoBase bool // выбрано «без базы»
Candidates []candidateView
}
type candidateView struct {
ID int64
Provider string
ProviderID string
Title string
Year int
URL string
Chosen bool
}
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "некорректный id", http.StatusBadRequest)
return
}
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "задача не найдена", http.StatusNotFound)
return
}
s.deps.Logger.Error("review data", "id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return
}
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"),
StateError: rd.Download.ErrorMsg.String,
Hints: rd.Hints,
}
if rec := rd.Recognition; rec != nil {
view.MediaType = string(rd.Plan.Type)
view.IsSeries = rd.Plan.Type == "series"
view.Title = rd.Plan.Title
view.OriginalTitle = rd.Plan.OriginalTitle
view.Year = rd.Plan.Year
view.Reasons = rec.ReasonList()
switch rd.Provider {
case "", "none":
view.NoBase = rd.Provider == "none"
default:
view.Provider = rd.Provider
view.ProviderID = rd.ProviderID
view.MatchURL = matchURL(rd, string(rd.Plan.Type))
}
if rec.Confidence.Valid {
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
}
view.Files = buildFileRows(rd.Plan, rd.Preview)
view.HasPlan = len(rd.Plan.Files) > 0
view.HasLinks = len(rd.Preview) > 0
for _, c := range rd.Candidates {
view.Candidates = append(view.Candidates, candidateView{
ID: c.ID,
Provider: c.Provider,
ProviderID: c.ProviderID,
Title: c.Title.String,
Year: int(c.Year.Int64),
URL: c.URL.String,
Chosen: c.Chosen,
})
}
}
s.render(w, "review.html", view)
}
// --- Действия ревью (POST → redirect) ---
func (s *server) handleApply(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
if err := s.deps.Reviewer.Apply(r.Context(), id); err != nil {
// Тонкий транспорт: ошибку воркера переводим в ответ, не логируя
// повторно (доменный слой уже залогировал реальный сбой).
redirectReview(w, r, id, userErr(r, err, id))
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) handleRefine(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
_ = r.ParseForm()
return s.deps.Reviewer.Refine(ctx, id, r.PostForm.Get("hint"))
})
}
func (s *server) handleRerecognize(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
return s.deps.Reviewer.Rerecognize(ctx, id)
})
}
func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) 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 int64) error {
_ = r.ParseForm()
return s.deps.Reviewer.IgnoreFile(ctx, id, r.PostForm.Get("src"))
})
}
func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
_ = r.ParseForm()
candidateID, err := strconv.ParseInt(r.PostForm.Get("candidate_id"), 10, 64)
if err != nil {
return errInvalidCandidate
}
return s.deps.Reviewer.ChooseCandidate(ctx, id, candidateID)
})
}
func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
_ = r.ParseForm()
return s.deps.Reviewer.SetProviderID(ctx, id, r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
})
}
func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
return s.deps.Reviewer.ClearProvider(ctx, id)
})
}
var errInvalidCandidate = errors.New("некорректный id кандидата")
func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
if err := s.deps.Reviewer.Defer(r.Context(), id); err != nil {
redirectReview(w, r, id, userErr(r, err, id))
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil {
redirectErr(w, r, userErr(r, err, id))
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// handleRelink повторно привязывает откатанную задачу: перезапускает
// распознавание, задача пройдёт recognizing → review для подтверждения.
func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil {
redirectErr(w, r, userErr(r, err, id))
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// reviewAction — общий помощник: выполнить действие и вернуться на страницу
// ревью (с ошибкой в ?err при неудаче).
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) error) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
if err := fn(r.Context(), id); err != nil {
// Тонкий транспорт: ошибку переводим в ?err= на странице ревью, не
// логируя повторно (доменный слой/валидация — не дело транспорта).
redirectReview(w, r, id, userErr(r, err, id))
return
}
redirectReview(w, r, id, "")
}
// matchURL выбирает ссылку на подтверждённую запись метабазы. Приоритет — URL
// выбранного кандидата, но только если его provider+id совпадают с эффективными
// (человек мог выбрать кандидата, затем вручную переопределить id — тогда
// кандидат указывает на другую запись). Иначе строим канонический URL; если не
// удаётся — возвращаем пусто (транспорт покажет матч текстом).
func matchURL(rd *worker.ReviewData, mediaType string) string {
for _, c := range rd.Candidates {
if c.Chosen && c.Provider == rd.Provider && c.ProviderID == rd.ProviderID &&
c.URL.Valid && c.URL.String != "" {
return c.URL.String
}
}
return providerURL(rd.Provider, rd.ProviderID, mediaType)
}
// providerURL строит канонический URL записи метабазы с учётом типа медиа.
// Пустой id или неизвестный провайдер → пусто.
func providerURL(provider, id, mediaType string) string {
if id == "" {
return ""
}
switch provider {
case "tmdb":
kind := "movie"
if mediaType == "series" {
kind = "tv"
}
return "https://www.themoviedb.org/" + kind + "/" + id
case "tvdb":
kind := "series"
if mediaType == "movie" {
kind = "movie"
}
return "https://www.thetvdb.com/dereferrer/" + kind + "/" + id
case "imdb":
return "https://www.imdb.com/title/" + id
default:
return ""
}
}
func redirectReview(w http.ResponseWriter, r *http.Request, id int64, msg string) {
u := "/review/" + strconv.FormatInt(id, 10)
if msg != "" {
u += "?err=" + url.QueryEscape(msg)
}
http.Redirect(w, r, u, http.StatusSeeOther)
}