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 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 } 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, "") } 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) }