- Список: серверные фильтр по группе состояний, поиск и пагинация (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>
306 lines
10 KiB
Go
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)
|
|
}
|