291 lines
8.6 KiB
Go
291 lines
8.6 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
|
|
"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 []reviewFileView
|
|
Preview []string
|
|
HasPlan bool
|
|
NoBase bool // выбрано «без базы»
|
|
Candidates []candidateView
|
|
}
|
|
|
|
type reviewFileView struct {
|
|
Src string
|
|
Role string
|
|
Season string
|
|
Episode string
|
|
Ignored bool
|
|
}
|
|
|
|
type candidateView struct {
|
|
ID int64
|
|
Provider string
|
|
ProviderID string
|
|
Title string
|
|
Year int
|
|
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, sql.ErrNoRows) {
|
|
http.Error(w, "задача не найдена", http.StatusNotFound)
|
|
return
|
|
}
|
|
s.deps.Logger.Error("review data", "id", id, "err", err)
|
|
http.Error(w, "internal error", 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)
|
|
}
|
|
for _, f := range rd.Plan.Files {
|
|
view.Files = append(view.Files, reviewFileView{
|
|
Src: f.Src,
|
|
Role: string(f.Role),
|
|
Season: intPtrStr(f.Season),
|
|
Episode: intPtrStr(f.Episode),
|
|
Ignored: f.Role == "ignore",
|
|
})
|
|
}
|
|
view.HasPlan = len(rd.Plan.Files) > 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),
|
|
Chosen: c.Chosen,
|
|
})
|
|
}
|
|
}
|
|
for _, l := range rd.Preview {
|
|
view.Preview = append(view.Preview, l.Dst)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := s.review.Execute(w, view); err != nil {
|
|
s.deps.Logger.Error("render review", "err", err)
|
|
}
|
|
}
|
|
|
|
// --- Действия ревью (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 {
|
|
s.deps.Logger.Warn("review action failed", "action", "apply", "id", id, "err", err)
|
|
redirectReview(w, r, id, err.Error())
|
|
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 {
|
|
s.deps.Logger.Warn("review action failed", "action", "defer", "id", id, "err", err)
|
|
redirectReview(w, r, id, err.Error())
|
|
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 {
|
|
s.deps.Logger.Warn("review action failed", "action", "undo", "id", id, "err", err)
|
|
redirectErr(w, r, err.Error())
|
|
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 {
|
|
s.deps.Logger.Warn("review action failed", "action", "relink", "id", id, "err", err)
|
|
redirectErr(w, r, err.Error())
|
|
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 {
|
|
s.deps.Logger.Warn("review action failed",
|
|
"action", r.URL.Path, "id", id, "err", err)
|
|
redirectReview(w, r, id, err.Error())
|
|
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)
|
|
}
|
|
|
|
func intPtrStr(p *int) string {
|
|
if p == nil {
|
|
return "—"
|
|
}
|
|
return strconv.Itoa(*p)
|
|
}
|