Идентичность на ULID: download_infohash, guarded-дедуп, миграция (ulid-identity)
Все сущности переехали с INTEGER AUTOINCREMENT на TEXT ULID (lowercase, internal/ident — единая точка генерации и разбора; oklog/ulid). Инфохэши загрузки — множество (download_infohash, v1/v2 гибридных торрентов): дедуп и сопоставление в поллинге по любому из хешей, magnet-парсер отдаёт оба хеша гибридной ссылки, усечённый v2-хеш v2-only раздач не хранится. Инвариант «не более одной активной загрузки на infohash» вместо снятого unique-индекса держат guarded-методы store в одной write-транзакции (_txlock=immediate): CreateDownloadIfNoActive (приём/adopt, с доносом недостающих хешей), ActivateIfNoOtherActive (retry/recovery/relink, отказ до побочных эффектов), guarded AddInfohashes; SetDownloadState отклоняет терминал→активное как механический бэкстоп. Миграция 0006 — первая Go-миграция goose: пересоздание таблиц при включённых FK, backfill ULID с timestamp из created_at (хронология id сохранена), разнос infohash, удаление idempotency_key. BREAKING: формат id в URL/логах/Telegram, REST-поля id (string) и infohashes (список). Новая конвенция docs/conventions/database.md (без числовых PK), корреляция в логах grep'ом по голому ULID, ER-схема обновлена. Спеки: новая capability identity, MODIFIED в state-reconciliation; change заархивирован. Пройдены ревью дизайна и кода (по 8 углов), все находки исправлены с регрессионными тестами. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,12 +12,13 @@ import (
|
||||
// --- Страница просмотра одной загрузки ---
|
||||
|
||||
type downloadDetailView struct {
|
||||
ID int64
|
||||
ID string
|
||||
Title string
|
||||
SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте»
|
||||
SourceFull string // полный источник (magnet) — блок «Информация о торренте»
|
||||
Infohash string
|
||||
Infohash string // первый хеш (для шапки)
|
||||
InfohashShort string
|
||||
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
|
||||
Context string
|
||||
State string
|
||||
Error string
|
||||
@@ -65,7 +66,8 @@ func detailTitle(d store.Download, rd *worker.ReviewData) string {
|
||||
func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "некорректный id", http.StatusBadRequest)
|
||||
// Невалидный id = несуществующая сущность; в БД не ходим.
|
||||
http.Error(w, "задача не найдена", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
@@ -85,8 +87,9 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
Title: detailTitle(d, rd),
|
||||
SourceType: string(d.SourceType),
|
||||
SourceFull: d.SourceRef,
|
||||
Infohash: d.Infohash.String,
|
||||
InfohashShort: shortenHash(d.Infohash.String),
|
||||
Infohash: d.PrimaryInfohash(),
|
||||
InfohashShort: shortenHash(d.PrimaryInfohash()),
|
||||
Infohashes: d.HashList(),
|
||||
Context: d.Context,
|
||||
State: string(d.State),
|
||||
Error: d.ErrorMsg.String,
|
||||
@@ -126,7 +129,7 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Живая статистика раздачи — со значениями уже в первом кадре; секция
|
||||
// деградирует (пустой контейнер), если торрент не сидирует/данных нет.
|
||||
l, ok := s.deps.Live.Live(d.Infohash.String)
|
||||
l, ok := s.liveFor(d)
|
||||
view.Seeding = buildSeeding(id, l, ok)
|
||||
|
||||
s.render(w, "download.html", view)
|
||||
|
||||
+51
-39
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
@@ -35,15 +36,15 @@ type Ingestor interface {
|
||||
|
||||
// Commander исполняет команды над задачей (worker.Worker).
|
||||
type Commander interface {
|
||||
Cancel(ctx context.Context, id int64) error
|
||||
Retry(ctx context.Context, id int64) error
|
||||
Cancel(ctx context.Context, id string) error
|
||||
Retry(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// Reader читает задачи (store.Store).
|
||||
type Reader interface {
|
||||
ListDownloads(ctx context.Context) ([]store.Download, error)
|
||||
ListDownloadsPage(ctx context.Context, f store.ListFilter) ([]store.Download, int, error)
|
||||
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
||||
GetDownload(ctx context.Context, id string) (*store.Download, error)
|
||||
}
|
||||
|
||||
// Deps — зависимости транспорта.
|
||||
@@ -178,7 +179,7 @@ type pageLink struct {
|
||||
}
|
||||
|
||||
type downloadView struct {
|
||||
ID int64
|
||||
ID string
|
||||
Title string // отображаемый заголовок карточки
|
||||
Infohash string // полный (для копирования)
|
||||
InfohashShort string // усечённый (для показа)
|
||||
@@ -269,7 +270,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
// Живой прогресс активных загрузок — со значениями уже в первом кадре
|
||||
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
|
||||
if v.IsDownloading {
|
||||
l, ok := s.deps.Live.Live(d.Infohash.String)
|
||||
l, ok := s.liveFor(d)
|
||||
v.Progress = buildProgress(d.ID, true, l, ok)
|
||||
}
|
||||
view.Downloads = append(view.Downloads, v)
|
||||
@@ -394,15 +395,15 @@ func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
|
||||
// --- REST API ---
|
||||
|
||||
type downloadDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceType string `json:"source_type"`
|
||||
Infohash string `json:"infohash,omitempty"`
|
||||
Context string `json:"context,omitempty"`
|
||||
State string `json:"state"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
ErrorMsg string `json:"error_msg,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"` // ULID (lowercase)
|
||||
SourceType string `json:"source_type"`
|
||||
Infohashes []string `json:"infohashes,omitempty"` // все хеши загрузки (v1 раньше v2)
|
||||
Context string `json:"context,omitempty"`
|
||||
State string `json:"state"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
ErrorMsg string `json:"error_msg,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type addRequest struct {
|
||||
@@ -411,16 +412,16 @@ type addRequest struct {
|
||||
}
|
||||
|
||||
type addResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Infohash string `json:"infohash"`
|
||||
State string `json:"state"`
|
||||
Deduplicated bool `json:"deduplicated"`
|
||||
ID string `json:"id"` // ULID (lowercase)
|
||||
Infohashes []string `json:"infohashes"` // хеши принятого источника (v1 раньше v2) — симметрично downloadDTO
|
||||
State string `json:"state"`
|
||||
Deduplicated bool `json:"deduplicated"`
|
||||
}
|
||||
|
||||
func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
|
||||
downloads, err := s.deps.Reader.ListDownloads(r.Context())
|
||||
if err != nil {
|
||||
s.apiErr(w, r, err, 0)
|
||||
s.apiErr(w, r, err, "")
|
||||
return
|
||||
}
|
||||
out := make([]downloadDTO, 0, len(downloads))
|
||||
@@ -433,7 +434,8 @@ func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
|
||||
// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
|
||||
writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
|
||||
return
|
||||
}
|
||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||
@@ -448,7 +450,7 @@ func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
|
||||
var req addRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный запрос", 0))
|
||||
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный запрос", ""))
|
||||
return
|
||||
}
|
||||
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
|
||||
@@ -465,7 +467,7 @@ func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, status, addResponse{
|
||||
ID: res.DownloadID,
|
||||
Infohash: res.Infohash,
|
||||
Infohashes: res.Infohashes,
|
||||
State: string(res.State),
|
||||
Deduplicated: res.Deduplicated,
|
||||
})
|
||||
@@ -479,10 +481,11 @@ func (s *server) handleAPIRetry(w http.ResponseWriter, r *http.Request) {
|
||||
s.apiCommand(w, r, s.deps.Commander.Retry)
|
||||
}
|
||||
|
||||
func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, int64) error) {
|
||||
func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, string) error) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
|
||||
// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
|
||||
writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
|
||||
return
|
||||
}
|
||||
if err := cmd(r.Context(), id); err != nil {
|
||||
@@ -494,7 +497,7 @@ func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(con
|
||||
}
|
||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]int64{"id": id})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"id": id})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toDTO(*d))
|
||||
@@ -506,7 +509,7 @@ func toDTO(d store.Download) downloadDTO {
|
||||
return downloadDTO{
|
||||
ID: d.ID,
|
||||
SourceType: string(d.SourceType),
|
||||
Infohash: d.Infohash.String,
|
||||
Infohashes: d.HashList(),
|
||||
Context: d.Context,
|
||||
State: string(d.State),
|
||||
ErrorCode: d.ErrorCode.String,
|
||||
@@ -521,8 +524,8 @@ func toView(d store.Download) downloadView {
|
||||
return downloadView{
|
||||
ID: d.ID,
|
||||
Title: downloadTitle(d),
|
||||
Infohash: d.Infohash.String,
|
||||
InfohashShort: shortenHash(d.Infohash.String),
|
||||
Infohash: d.PrimaryInfohash(),
|
||||
InfohashShort: shortenHash(d.PrimaryInfohash()),
|
||||
Context: d.Context,
|
||||
State: state,
|
||||
Error: d.ErrorMsg.String,
|
||||
@@ -586,12 +589,21 @@ func shorten(s string, n int) string {
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
func pathID(r *http.Request) (int64, error) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.New("invalid id")
|
||||
// pathID валидирует {id} из URL как ULID и нормализует к lowercase — до
|
||||
// любого обращения к БД (сравнение в SQLite побайтовое). Невалидный id
|
||||
// трактуется вызывающими как несуществующая сущность (404).
|
||||
func pathID(r *http.Request) (string, error) {
|
||||
return ident.Parse(chi.URLParam(r, "id"))
|
||||
}
|
||||
|
||||
// liveFor достаёт живую телеметрию по любому из хешей загрузки.
|
||||
func (s *server) liveFor(d store.Download) (worker.Live, bool) {
|
||||
for _, h := range d.HashList() {
|
||||
if l, ok := s.deps.Live.Live(h); ok {
|
||||
return l, true
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
return worker.Live{}, false
|
||||
}
|
||||
|
||||
func redirectErr(w http.ResponseWriter, r *http.Request, msg string) {
|
||||
@@ -627,9 +639,9 @@ func classifyErr(err error) (int, string) {
|
||||
// errBody — тело ошибки REST API: нейтральное сообщение + корреляционный ключ
|
||||
// для владельца (download_id, если операция привязана к загрузке, иначе
|
||||
// request_id запроса), по которому он найдёт полную ошибку в логах.
|
||||
func errBody(r *http.Request, msg string, downloadID int64) map[string]any {
|
||||
func errBody(r *http.Request, msg string, downloadID string) map[string]any {
|
||||
body := map[string]any{"error": msg}
|
||||
if downloadID > 0 {
|
||||
if downloadID != "" {
|
||||
body["download_id"] = downloadID
|
||||
} else {
|
||||
body["request_id"] = middleware.GetReqID(r.Context())
|
||||
@@ -638,7 +650,7 @@ func errBody(r *http.Request, msg string, downloadID int64) map[string]any {
|
||||
}
|
||||
|
||||
// apiErr пишет ответ об ошибке REST API по доменной ошибке (статус + тело).
|
||||
func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downloadID int64) {
|
||||
func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downloadID string) {
|
||||
status, msg := classifyErr(err)
|
||||
writeJSON(w, status, errBody(r, msg, downloadID))
|
||||
}
|
||||
@@ -646,10 +658,10 @@ func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downl
|
||||
// userErr — сообщение публичного канала для веб-UI: нейтральный текст по
|
||||
// доменной ошибке + корреляционный ключ владельцу (download_id, если операция
|
||||
// привязана к загрузке, иначе request_id). Сырой текст ошибки наружу не идёт.
|
||||
func userErr(r *http.Request, err error, downloadID int64) string {
|
||||
func userErr(r *http.Request, err error, downloadID string) string {
|
||||
_, msg := classifyErr(err)
|
||||
if downloadID > 0 {
|
||||
return fmt.Sprintf("%s (download_id=%d)", msg, downloadID)
|
||||
if downloadID != "" {
|
||||
return fmt.Sprintf("%s (download_id=%s)", msg, downloadID)
|
||||
}
|
||||
return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
|
||||
}
|
||||
|
||||
@@ -21,6 +21,14 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
|
||||
// Валидные lowercase-ULID для маршрутов (pathID валидирует формат).
|
||||
const (
|
||||
tid = "01arz3ndektsv4rrffq69g5fav"
|
||||
tid2 = "01arz3ndektsv4rrffq69g5fb0"
|
||||
cid = "01arz3ndektsv4rrffq69g5fc0"
|
||||
cid2 = "01arz3ndektsv4rrffq69g5fd0"
|
||||
)
|
||||
|
||||
type fakeIngestor struct {
|
||||
res ingest.Result
|
||||
err error
|
||||
@@ -33,12 +41,12 @@ func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Res
|
||||
}
|
||||
|
||||
type fakeCommander struct {
|
||||
cancelled []int64
|
||||
retried []int64
|
||||
cancelled []string
|
||||
retried []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeCommander) Cancel(_ context.Context, id int64) error {
|
||||
func (f *fakeCommander) Cancel(_ context.Context, id string) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
@@ -46,7 +54,7 @@ func (f *fakeCommander) Cancel(_ context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeCommander) Retry(_ context.Context, id int64) error {
|
||||
func (f *fakeCommander) Retry(_ context.Context, id string) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
@@ -76,7 +84,7 @@ func (f *fakeReader) ListDownloadsPage(_ context.Context, flt store.ListFilter)
|
||||
return f.list, total, nil
|
||||
}
|
||||
|
||||
func (f *fakeReader) GetDownload(_ context.Context, id int64) (*store.Download, error) {
|
||||
func (f *fakeReader) GetDownload(_ context.Context, id string) (*store.Download, error) {
|
||||
if f.get != nil {
|
||||
return f.get, nil
|
||||
}
|
||||
@@ -98,7 +106,7 @@ func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
|
||||
}
|
||||
|
||||
func TestAPIAdd(t *testing.T) {
|
||||
ing := &fakeIngestor{res: ingest.Result{DownloadID: 1, Infohash: "abc", State: store.StateDownloading}}
|
||||
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, Infohashes: []string{"abc"}, State: store.StateDownloading}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads", "application/json",
|
||||
@@ -112,7 +120,7 @@ func TestAPIAdd(t *testing.T) {
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
if got["id"].(float64) != 1 || got["state"] != "downloading" {
|
||||
if got["id"] != tid || got["state"] != "downloading" {
|
||||
t.Errorf("body = %v", got)
|
||||
}
|
||||
if ing.lastReq.Context != "Дюна" {
|
||||
@@ -138,8 +146,9 @@ func TestAPIAddBadInput(t *testing.T) {
|
||||
|
||||
func TestAPIList(t *testing.T) {
|
||||
reader := &fakeReader{list: []store.Download{
|
||||
{ID: 2, SourceType: store.SourceMagnet, State: store.StateCompleted, Infohash: store.NullString("abc")},
|
||||
{ID: 1, SourceType: store.SourceMagnet, State: store.StateDownloading},
|
||||
{ID: tid2, SourceType: store.SourceMagnet, State: store.StateCompleted,
|
||||
Infohashes: []store.Infohash{{DownloadID: tid2, Infohash: "abc", Kind: store.HashV1}}},
|
||||
{ID: tid, SourceType: store.SourceMagnet, State: store.StateDownloading},
|
||||
}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||
|
||||
@@ -153,7 +162,8 @@ func TestAPIList(t *testing.T) {
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0]["state"] != "completed" || got[0]["infohash"] != "abc" {
|
||||
hashes, _ := got[0]["infohashes"].([]any)
|
||||
if got[0]["state"] != "completed" || len(hashes) != 1 || hashes[0] != "abc" {
|
||||
t.Errorf("first = %v", got[0])
|
||||
}
|
||||
}
|
||||
@@ -162,7 +172,7 @@ func TestAPICancel(t *testing.T) {
|
||||
cmd := &fakeCommander{}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/5/cancel", "", nil)
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -170,7 +180,7 @@ func TestAPICancel(t *testing.T) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if len(cmd.cancelled) != 1 || cmd.cancelled[0] != 5 {
|
||||
if len(cmd.cancelled) != 1 || cmd.cancelled[0] != tid {
|
||||
t.Errorf("cancel вызван неверно: %v", cmd.cancelled)
|
||||
}
|
||||
}
|
||||
@@ -179,7 +189,7 @@ func TestUIRetry(t *testing.T) {
|
||||
cmd := &fakeCommander{}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/ui/downloads/5/retry", "application/x-www-form-urlencoded", nil)
|
||||
resp, err := http.Post(srv.URL+"/ui/downloads/"+tid+"/retry", "application/x-www-form-urlencoded", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -187,7 +197,7 @@ func TestUIRetry(t *testing.T) {
|
||||
if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if len(cmd.retried) != 1 || cmd.retried[0] != 5 {
|
||||
if len(cmd.retried) != 1 || cmd.retried[0] != tid {
|
||||
t.Errorf("retry вызван неверно: %v", cmd.retried)
|
||||
}
|
||||
}
|
||||
@@ -197,7 +207,7 @@ func TestAPICommandConflict(t *testing.T) {
|
||||
cmd := &fakeCommander{err: fmt.Errorf("cancel: download 5 in wrong state: %w", worker.ErrConflict)}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/5/cancel", "", nil)
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -207,14 +217,14 @@ func TestAPICommandConflict(t *testing.T) {
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
if got["download_id"].(float64) != 5 {
|
||||
if got["download_id"] != tid {
|
||||
t.Errorf("download_id корреляции нет: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexRenders(t *testing.T) {
|
||||
reader := &fakeReader{list: []store.Download{
|
||||
{ID: 1, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
|
||||
{ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
|
||||
}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||
|
||||
@@ -239,7 +249,7 @@ func (e ingestErr) Error() string { return string(e) }
|
||||
func TestIndexTitleFromDisplayName(t *testing.T) {
|
||||
// Заголовок карточки — имя раздачи (display_name), а не сырой magnet.
|
||||
reader := &fakeReader{list: []store.Download{{
|
||||
ID: 1, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
|
||||
ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
|
||||
DisplayName: "Дюна: Часть вторая (2024)", State: store.StateDownloading,
|
||||
}}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||
@@ -301,11 +311,11 @@ func TestIndexPageClamp(t *testing.T) {
|
||||
func detailReviewData(provider, providerID, chosenID, chosenURL string) *worker.ReviewData {
|
||||
rd := &worker.ReviewData{
|
||||
Download: store.Download{
|
||||
ID: 1, State: store.StateReview, SourceType: store.SourceMagnet,
|
||||
ID: tid, State: store.StateReview, SourceType: store.SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:deadbeef", DisplayName: "Дюна",
|
||||
Infohash: store.NullString("deadbeef"),
|
||||
Infohashes: []store.Infohash{{DownloadID: tid, Infohash: "deadbeef", Kind: store.HashV1}},
|
||||
},
|
||||
Recognition: &store.Recognition{ID: 1, DownloadID: 1, IsCurrent: true},
|
||||
Recognition: &store.Recognition{ID: "1", DownloadID: tid, IsCurrent: true},
|
||||
Plan: recognize.Plan{
|
||||
Type: recognize.MediaMovie, Title: "Дюна", Year: 2024,
|
||||
Files: []recognize.PlanFile{{Src: "dune.mkv", Role: recognize.RoleMain}},
|
||||
@@ -316,7 +326,7 @@ func detailReviewData(provider, providerID, chosenID, chosenURL string) *worker.
|
||||
}
|
||||
if chosenID != "" {
|
||||
rd.Candidates = []store.MetadataCandidate{{
|
||||
ID: 10, Provider: "tmdb", ProviderID: chosenID, Chosen: true,
|
||||
ID: cid, Provider: "tmdb", ProviderID: chosenID, Chosen: true,
|
||||
URL: store.NullString(chosenURL),
|
||||
}}
|
||||
}
|
||||
@@ -329,7 +339,7 @@ func TestDownloadMatchLinkFromCandidate(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/download/1")
|
||||
resp, err := http.Get(srv.URL + "/download/" + tid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -347,7 +357,7 @@ func TestDownloadMatchLinkMismatchUsesBuilt(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/download/1")
|
||||
resp, err := http.Get(srv.URL + "/download/" + tid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -366,7 +376,7 @@ func TestDownloadTorrentInfoBlock(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/download/1")
|
||||
resp, err := http.Get(srv.URL + "/download/" + tid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -384,81 +394,81 @@ func TestDownloadTorrentInfoBlock(t *testing.T) {
|
||||
type fakeReviewer struct {
|
||||
data *worker.ReviewData
|
||||
applyErr error
|
||||
refined map[int64]string
|
||||
typed map[int64]string
|
||||
ignored map[int64]string
|
||||
chosen map[int64]int64
|
||||
providerSet map[int64]string
|
||||
applied []int64
|
||||
deferred []int64
|
||||
undone []int64
|
||||
relinked []int64
|
||||
rerecognized []int64
|
||||
cleared []int64
|
||||
refined map[string]string
|
||||
typed map[string]string
|
||||
ignored map[string]string
|
||||
chosen map[string]string
|
||||
providerSet map[string]string
|
||||
applied []string
|
||||
deferred []string
|
||||
undone []string
|
||||
relinked []string
|
||||
rerecognized []string
|
||||
cleared []string
|
||||
}
|
||||
|
||||
func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) {
|
||||
func (f *fakeReviewer) ReviewData(_ context.Context, _ string) (*worker.ReviewData, error) {
|
||||
return f.data, nil
|
||||
}
|
||||
func (f *fakeReviewer) Apply(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Apply(_ context.Context, id string) error {
|
||||
if f.applyErr != nil {
|
||||
return f.applyErr
|
||||
}
|
||||
f.applied = append(f.applied, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Refine(_ context.Context, id int64, hint string) error {
|
||||
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
|
||||
if f.refined == nil {
|
||||
f.refined = map[int64]string{}
|
||||
f.refined = map[string]string{}
|
||||
}
|
||||
f.refined[id] = hint
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) SetType(_ context.Context, id int64, t string) error {
|
||||
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
|
||||
if f.typed == nil {
|
||||
f.typed = map[int64]string{}
|
||||
f.typed = map[string]string{}
|
||||
}
|
||||
f.typed[id] = t
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) IgnoreFile(_ context.Context, id int64, src string) error {
|
||||
func (f *fakeReviewer) IgnoreFile(_ context.Context, id string, src string) error {
|
||||
if f.ignored == nil {
|
||||
f.ignored = map[int64]string{}
|
||||
f.ignored = map[string]string{}
|
||||
}
|
||||
f.ignored[id] = src
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Defer(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Defer(_ context.Context, id string) error {
|
||||
f.deferred = append(f.deferred, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Undo(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Undo(_ context.Context, id string) error {
|
||||
f.undone = append(f.undone, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Relink(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Relink(_ context.Context, id string) error {
|
||||
f.relinked = append(f.relinked, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Rerecognize(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Rerecognize(_ context.Context, id string) error {
|
||||
f.rerecognized = append(f.rerecognized, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error {
|
||||
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID string) error {
|
||||
if f.chosen == nil {
|
||||
f.chosen = map[int64]int64{}
|
||||
f.chosen = map[string]string{}
|
||||
}
|
||||
f.chosen[id] = candidateID
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) SetProviderID(_ context.Context, id int64, provider, providerID string) error {
|
||||
func (f *fakeReviewer) SetProviderID(_ context.Context, id string, provider, providerID string) error {
|
||||
if f.providerSet == nil {
|
||||
f.providerSet = map[int64]string{}
|
||||
f.providerSet = map[string]string{}
|
||||
}
|
||||
f.providerSet[id] = provider + ":" + providerID
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) ClearProvider(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) ClearProvider(_ context.Context, id string) error {
|
||||
f.cleared = append(f.cleared, id)
|
||||
return nil
|
||||
}
|
||||
@@ -466,9 +476,9 @@ func (f *fakeReviewer) ClearProvider(_ context.Context, id int64) error {
|
||||
func seriesReviewData() *worker.ReviewData {
|
||||
s, e := 2, 1
|
||||
return &worker.ReviewData{
|
||||
Download: store.Download{ID: 1, State: store.StateReview, SourceRef: "magnet:?xt=urn:btih:abc"},
|
||||
Download: store.Download{ID: tid, State: store.StateReview, SourceRef: "magnet:?xt=urn:btih:abc"},
|
||||
Recognition: &store.Recognition{
|
||||
ID: 1, DownloadID: 1, IsCurrent: true, Reasons: `["нет матча в базе"]`,
|
||||
ID: "1", DownloadID: tid, IsCurrent: true, Reasons: `["нет матча в базе"]`,
|
||||
},
|
||||
Plan: recognize.Plan{
|
||||
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
|
||||
@@ -480,9 +490,9 @@ func seriesReviewData() *worker.ReviewData {
|
||||
{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
|
||||
},
|
||||
Candidates: []store.MetadataCandidate{
|
||||
{ID: 10, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
|
||||
{ID: cid, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
|
||||
Year: sql.NullInt64{Int64: 2014, Valid: true}},
|
||||
{ID: 11, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")},
|
||||
{ID: cid2, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")},
|
||||
},
|
||||
Hints: []string{"второй сезон"},
|
||||
}
|
||||
@@ -500,7 +510,7 @@ func TestReviewRenders(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/review/1")
|
||||
resp, err := http.Get(srv.URL + "/review/" + tid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -529,7 +539,7 @@ func TestReviewShowsMatchLink(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/review/1")
|
||||
resp, err := http.Get(srv.URL + "/review/" + tid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -545,16 +555,16 @@ func TestChooseCandidate(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/1/candidate",
|
||||
map[string][]string{"candidate_id": {"10"}})
|
||||
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/candidate",
|
||||
map[string][]string{"candidate_id": {cid}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if rv.chosen[1] != 10 {
|
||||
t.Errorf("ChooseCandidate получил %d", rv.chosen[1])
|
||||
if rv.chosen[tid] != cid {
|
||||
t.Errorf("ChooseCandidate получил %q", rv.chosen[tid])
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
|
||||
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) {
|
||||
t.Errorf("Location = %q", loc)
|
||||
}
|
||||
}
|
||||
@@ -565,18 +575,18 @@ func TestSetProviderAndNoBase(t *testing.T) {
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
cl := noRedirectClient()
|
||||
|
||||
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/provider",
|
||||
if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/provider",
|
||||
map[string][]string{"provider": {"tvdb"}, "provider_id": {"269613"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rv.providerSet[1] != "tvdb:269613" {
|
||||
t.Errorf("SetProviderID получил %q", rv.providerSet[1])
|
||||
if rv.providerSet[tid] != "tvdb:269613" {
|
||||
t.Errorf("SetProviderID получил %q", rv.providerSet[tid])
|
||||
}
|
||||
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/1/nobase", "", nil); err != nil {
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/nobase", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rv.cleared) != 1 || rv.cleared[0] != 1 {
|
||||
if len(rv.cleared) != 1 || rv.cleared[0] != tid {
|
||||
t.Errorf("ClearProvider = %v", rv.cleared)
|
||||
}
|
||||
}
|
||||
@@ -586,7 +596,7 @@ func TestApplyRedirectsToIndex(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/1/apply", "", nil)
|
||||
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/"+tid+"/apply", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -607,13 +617,13 @@ func TestApplyCollisionRedirectsToReview(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/1/apply", "", nil)
|
||||
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/"+tid+"/apply", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
|
||||
t.Errorf("Location = %q, want /review/1?err=...", loc)
|
||||
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) {
|
||||
t.Errorf("Location = %q, want /review/{id}?err=...", loc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,16 +632,16 @@ func TestRefinePostsHint(t *testing.T) {
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/1/refine",
|
||||
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/refine",
|
||||
map[string][]string{"hint": {"это второй сезон"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if rv.refined[1] != "это второй сезон" {
|
||||
t.Errorf("Refine получил %q", rv.refined[1])
|
||||
if rv.refined[tid] != "это второй сезон" {
|
||||
t.Errorf("Refine получил %q", rv.refined[tid])
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
|
||||
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) {
|
||||
t.Errorf("Location = %q", loc)
|
||||
}
|
||||
}
|
||||
@@ -642,20 +652,20 @@ func TestIgnoreAndType(t *testing.T) {
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
cl := noRedirectClient()
|
||||
|
||||
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/ignore",
|
||||
if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/ignore",
|
||||
map[string][]string{"src": {"Fargo/sample.mkv"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rv.ignored[1] != "Fargo/sample.mkv" {
|
||||
t.Errorf("IgnoreFile получил %q", rv.ignored[1])
|
||||
if rv.ignored[tid] != "Fargo/sample.mkv" {
|
||||
t.Errorf("IgnoreFile получил %q", rv.ignored[tid])
|
||||
}
|
||||
|
||||
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/type",
|
||||
if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/type",
|
||||
map[string][]string{"type": {"movie"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rv.typed[1] != "movie" {
|
||||
t.Errorf("SetType получил %q", rv.typed[1])
|
||||
if rv.typed[tid] != "movie" {
|
||||
t.Errorf("SetType получил %q", rv.typed[tid])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,10 +675,10 @@ func TestUndoAndDefer(t *testing.T) {
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
cl := noRedirectClient()
|
||||
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/1/undo", "", nil); err != nil {
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/undo", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/1/defer", "", nil); err != nil {
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/defer", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rv.undone) != 1 || len(rv.deferred) != 1 {
|
||||
@@ -682,11 +692,11 @@ func TestRelink(t *testing.T) {
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
cl := noRedirectClient()
|
||||
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/1/relink", "", nil); err != nil {
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/relink", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rv.relinked) != 1 || rv.relinked[0] != 1 {
|
||||
t.Errorf("relinked = %v, want [1]", rv.relinked)
|
||||
if len(rv.relinked) != 1 || rv.relinked[0] != tid {
|
||||
t.Errorf("relinked = %v, want [%s]", rv.relinked, tid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,10 +706,10 @@ func TestRerecognize(t *testing.T) {
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
cl := noRedirectClient()
|
||||
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/1/rerecognize", "", nil); err != nil {
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/rerecognize", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rv.rerecognized) != 1 || rv.rerecognized[0] != 1 {
|
||||
t.Errorf("rerecognized = %v, want [1]", rv.rerecognized)
|
||||
if len(rv.rerecognized) != 1 || rv.rerecognized[0] != tid {
|
||||
t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false }
|
||||
// /progress). Active управляется store-состоянием (downloading), а не qbt:
|
||||
// когда задача покидает downloading, фрагмент возвращается без поллинга.
|
||||
type progressView struct {
|
||||
ID int64
|
||||
ID string
|
||||
Active bool // store-состояние downloading → показываем бар и поллим
|
||||
Has bool // есть данные снимка
|
||||
Percent int
|
||||
@@ -39,7 +39,7 @@ type progressView struct {
|
||||
// Has истинно только если торрент сидирует и данные есть — иначе секция
|
||||
// деградирует (пустой контейнер, поллинг прекращается).
|
||||
type seedingView struct {
|
||||
ID int64
|
||||
ID string
|
||||
Has bool
|
||||
Percent int
|
||||
Ratio string
|
||||
@@ -49,7 +49,7 @@ type seedingView struct {
|
||||
UpSpeed string
|
||||
}
|
||||
|
||||
func buildProgress(id int64, active bool, l worker.Live, ok bool) progressView {
|
||||
func buildProgress(id string, active bool, l worker.Live, ok bool) progressView {
|
||||
v := progressView{ID: id, Active: active}
|
||||
if ok {
|
||||
v.Has = true
|
||||
@@ -64,7 +64,7 @@ func buildProgress(id int64, active bool, l worker.Live, ok bool) progressView {
|
||||
return v
|
||||
}
|
||||
|
||||
func buildSeeding(id int64, l worker.Live, ok bool) seedingView {
|
||||
func buildSeeding(id string, l worker.Live, ok bool) seedingView {
|
||||
v := seedingView{ID: id}
|
||||
if ok && l.Seeding {
|
||||
v.Has = true
|
||||
@@ -82,7 +82,7 @@ func buildSeeding(id int64, l worker.Live, ok bool) seedingView {
|
||||
func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "некорректный id", http.StatusBadRequest)
|
||||
http.Error(w, "не найдено", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||
@@ -91,7 +91,7 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
active := d.State == store.StateDownloading
|
||||
l, ok := s.deps.Live.Live(d.Infohash.String)
|
||||
l, ok := s.liveFor(*d)
|
||||
s.render(w, "progress", buildProgress(id, active, l, ok))
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "некорректный id", http.StatusBadRequest)
|
||||
http.Error(w, "не найдено", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||
@@ -107,13 +107,13 @@ func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
|
||||
s.fragErr(w, err, id)
|
||||
return
|
||||
}
|
||||
l, ok := s.deps.Live.Live(d.Infohash.String)
|
||||
l, ok := s.liveFor(*d)
|
||||
s.render(w, "seeding", buildSeeding(id, l, ok))
|
||||
}
|
||||
|
||||
// fragErr транслирует ошибку чтения задачи для фрагмент-роутов: ErrNotFound →
|
||||
// 404, прочее → 500 (полная ошибка уже залогирована на доменной границе).
|
||||
func (s *server) fragErr(w http.ResponseWriter, err error, id int64) {
|
||||
func (s *server) fragErr(w http.ResponseWriter, err error, id string) {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, "не найдено", http.StatusNotFound)
|
||||
return
|
||||
|
||||
@@ -12,16 +12,16 @@ import (
|
||||
// TestFragProgressDownloading: активная задача → фрагмент с прогрессом,
|
||||
// значениями снимка и атрибутами htmx-поллинга.
|
||||
func TestFragProgressDownloading(t *testing.T) {
|
||||
dl := store.Download{ID: 5, Infohash: store.NullString("ih5"), State: store.StateDownloading}
|
||||
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDownloading}
|
||||
lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
|
||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv)
|
||||
|
||||
rr := get(t, h, "/fragments/downloads/5/progress")
|
||||
rr := get(t, h, "/fragments/downloads/"+testULID+"/progress")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{`hx-trigger="every 3s"`, "/fragments/downloads/5/progress", "width:42%", "42%"} {
|
||||
for _, want := range []string{`hx-trigger="every 3s"`, "/fragments/downloads/" + testULID + "/progress", "width:42%", "42%"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body)
|
||||
}
|
||||
@@ -31,10 +31,10 @@ func TestFragProgressDownloading(t *testing.T) {
|
||||
// TestFragProgressStopsWhenNotDownloading: когда задача покинула downloading,
|
||||
// фрагмент отдаётся без атрибутов поллинга (поллинг прекращается).
|
||||
func TestFragProgressStopsWhenNotDownloading(t *testing.T) {
|
||||
dl := store.Download{ID: 5, Infohash: store.NullString("ih5"), State: store.StateDone}
|
||||
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDone}
|
||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
|
||||
|
||||
rr := get(t, h, "/fragments/downloads/5/progress")
|
||||
rr := get(t, h, "/fragments/downloads/"+testULID+"/progress")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
@@ -46,13 +46,13 @@ func TestFragProgressStopsWhenNotDownloading(t *testing.T) {
|
||||
// TestFragSeeding: сидирующая задача → секция «Раздача» со статистикой и
|
||||
// поллингом.
|
||||
func TestFragSeeding(t *testing.T) {
|
||||
dl := store.Download{ID: 9, Infohash: store.NullString("ih9"), State: store.StateDone}
|
||||
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih9", Kind: store.HashV1}}, State: store.StateDone}
|
||||
lv := stubLive{m: map[string]worker.Live{"ih9": {
|
||||
Seeding: true, Progress: 1, Ratio: 2.41, Seeds: 38, Peers: 14, Uploaded: 1 << 30, UpSpeed: 1153433,
|
||||
}}}
|
||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv)
|
||||
|
||||
rr := get(t, h, "/fragments/downloads/9/seeding")
|
||||
rr := get(t, h, "/fragments/downloads/"+testULID+"/seeding")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
@@ -66,10 +66,10 @@ func TestFragSeeding(t *testing.T) {
|
||||
|
||||
// TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет.
|
||||
func TestFragSeedingDegrades(t *testing.T) {
|
||||
dl := store.Download{ID: 9, Infohash: store.NullString("ih9"), State: store.StateDone}
|
||||
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih9", Kind: store.HashV1}}, State: store.StateDone}
|
||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
|
||||
|
||||
rr := get(t, h, "/fragments/downloads/9/seeding")
|
||||
rr := get(t, h, "/fragments/downloads/"+testULID+"/seeding")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func TestFragSeedingDegrades(t *testing.T) {
|
||||
// TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже
|
||||
// в первом кадре (значения снимка) и атрибуты поллинга.
|
||||
func TestIndexCardShowsLiveProgress(t *testing.T) {
|
||||
dl := store.Download{ID: 3, SourceRef: "The.Bear.S03", Infohash: store.NullString("ih3"), State: store.StateDownloading}
|
||||
dl := store.Download{ID: testULID, SourceRef: "The.Bear.S03", Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih3", Kind: store.HashV1}}, State: store.StateDownloading}
|
||||
lv := stubLive{m: map[string]worker.Live{"ih3": {Progress: 0.46, DlSpeed: 6400000, ETA: 720}}}
|
||||
h := testRouterLive(t, stubReader{list: []store.Download{dl}}, stubReviewer{}, lv)
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestIndexCardShowsLiveProgress(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/3/progress"} {
|
||||
for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/" + testULID + "/progress"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("карточка без живого прогресса: нет %q", want)
|
||||
}
|
||||
@@ -101,7 +101,11 @@ func TestIndexCardShowsLiveProgress(t *testing.T) {
|
||||
// TestFragNotFound: фрагмент несуществующей задачи → 404.
|
||||
func TestFragNotFound(t *testing.T) {
|
||||
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
|
||||
if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound {
|
||||
if rr := get(t, h, "/fragments/downloads/01arz3ndektsv4rrffq69g5fff/progress"); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
// Невалидный id → 404 без похода в БД.
|
||||
if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status(invalid id) = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { r
|
||||
func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) {
|
||||
return s.list, len(s.list), nil
|
||||
}
|
||||
func (s stubReader) GetDownload(context.Context, int64) (*store.Download, error) {
|
||||
func (s stubReader) GetDownload(context.Context, string) (*store.Download, error) {
|
||||
if s.one == nil {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
@@ -33,23 +33,23 @@ func (s stubReader) GetDownload(context.Context, int64) (*store.Download, error)
|
||||
// stubReviewer — Reviewer-заглушка (нужна для /download/{id}).
|
||||
type stubReviewer struct{ data *worker.ReviewData }
|
||||
|
||||
func (s stubReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) {
|
||||
func (s stubReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
|
||||
if s.data == nil {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return s.data, nil
|
||||
}
|
||||
func (stubReviewer) Apply(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) Refine(context.Context, int64, string) error { return nil }
|
||||
func (stubReviewer) SetType(context.Context, int64, string) error { return nil }
|
||||
func (stubReviewer) IgnoreFile(context.Context, int64, string) error { return nil }
|
||||
func (stubReviewer) Defer(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) Undo(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) Relink(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) Rerecognize(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) ChooseCandidate(context.Context, int64, int64) error { return nil }
|
||||
func (stubReviewer) SetProviderID(context.Context, int64, string, string) error { return nil }
|
||||
func (stubReviewer) ClearProvider(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) Apply(context.Context, string) error { return nil }
|
||||
func (stubReviewer) Refine(context.Context, string, string) error { return nil }
|
||||
func (stubReviewer) SetType(context.Context, string, string) error { return nil }
|
||||
func (stubReviewer) IgnoreFile(context.Context, string, string) error { return nil }
|
||||
func (stubReviewer) Defer(context.Context, string) error { return nil }
|
||||
func (stubReviewer) Undo(context.Context, string) error { return nil }
|
||||
func (stubReviewer) Relink(context.Context, string) error { return nil }
|
||||
func (stubReviewer) Rerecognize(context.Context, string) error { return nil }
|
||||
func (stubReviewer) ChooseCandidate(context.Context, string, string) error { return nil }
|
||||
func (stubReviewer) SetProviderID(context.Context, string, string, string) error { return nil }
|
||||
func (stubReviewer) ClearProvider(context.Context, string) error { return nil }
|
||||
|
||||
// stubLive — заглушка источника живой телеметрии.
|
||||
type stubLive struct{ m map[string]worker.Live }
|
||||
@@ -85,9 +85,14 @@ func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
||||
return rr
|
||||
}
|
||||
|
||||
// testULID — валидный lowercase-ULID для маршрутов (pathID валидирует формат).
|
||||
const testULID = "01arz3ndektsv4rrffq69g5fav"
|
||||
|
||||
// TestRouterRendersPages проверяет, что шаблоны парсятся и страницы рендерятся.
|
||||
func TestRouterRendersPages(t *testing.T) {
|
||||
dl := store.Download{ID: 7, SourceRef: "Fargo.S02", Infohash: store.NullString("a1b2c3d4e5f6a7b8"), State: store.StateReview}
|
||||
dl := store.Download{ID: testULID, SourceRef: "Fargo.S02",
|
||||
Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "a1b2c3d4e5f6a7b8", Kind: store.HashV1}},
|
||||
State: store.StateReview}
|
||||
h := testRouter(t,
|
||||
stubReader{list: []store.Download{dl}, one: &dl},
|
||||
stubReviewer{data: &worker.ReviewData{Download: dl}},
|
||||
@@ -99,8 +104,8 @@ func TestRouterRendersPages(t *testing.T) {
|
||||
t.Errorf("index не содержит бейдж статуса")
|
||||
}
|
||||
|
||||
if rr := get(t, h, "/download/7"); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /download/7 = %d, want 200", rr.Code)
|
||||
if rr := get(t, h, "/download/"+testULID); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /download/{id} = %d, want 200", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +127,27 @@ func TestStaticServed(t *testing.T) {
|
||||
// TestDownloadNotFound — несуществующая загрузка → 404.
|
||||
func TestDownloadNotFound(t *testing.T) {
|
||||
h := testRouter(t, stubReader{}, stubReviewer{})
|
||||
if rr := get(t, h, "/download/999"); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /download/999 = %d, want 404", rr.Code)
|
||||
if rr := get(t, h, "/download/01arz3ndektsv4rrffq69g5fff"); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /download/{missing} = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadInvalidID — синтаксически невалидный id → 404 без похода в БД.
|
||||
func TestDownloadInvalidID(t *testing.T) {
|
||||
h := testRouter(t, stubReader{}, stubReviewer{})
|
||||
for _, path := range []string{"/download/999", "/download/abc!!!", "/review/12"} {
|
||||
if rr := get(t, h, path); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET %s = %d, want 404 (невалидный id = несуществующая сущность)", path, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadUppercaseIDNormalized — uppercase-вариант id ведёт на ту же
|
||||
// страницу (нормализация на входной границе).
|
||||
func TestDownloadUppercaseIDNormalized(t *testing.T) {
|
||||
dl := store.Download{ID: testULID, SourceRef: "X", State: store.StateReview}
|
||||
h := testRouter(t, stubReader{one: &dl}, stubReviewer{data: &worker.ReviewData{Download: dl}})
|
||||
if rr := get(t, h, "/download/"+strings.ToUpper(testULID)); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /download/{UPPERCASE} = %d, want 200", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
+29
-26
@@ -7,30 +7,31 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"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
|
||||
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
|
||||
Apply(ctx context.Context, id 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
|
||||
Defer(ctx context.Context, id string) error
|
||||
Undo(ctx context.Context, id string) error
|
||||
Relink(ctx context.Context, id string) error
|
||||
Rerecognize(ctx context.Context, id string) error
|
||||
ChooseCandidate(ctx context.Context, id, candidateID string) error
|
||||
SetProviderID(ctx context.Context, id string, provider, providerID string) error
|
||||
ClearProvider(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// --- Представление страницы ревью ---
|
||||
|
||||
type reviewView struct {
|
||||
ID int64
|
||||
ID string
|
||||
Source string
|
||||
Context string
|
||||
State string
|
||||
@@ -55,7 +56,7 @@ type reviewView struct {
|
||||
}
|
||||
|
||||
type candidateView struct {
|
||||
ID int64
|
||||
ID string
|
||||
Provider string
|
||||
ProviderID string
|
||||
Title string
|
||||
@@ -67,7 +68,8 @@ type candidateView struct {
|
||||
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "некорректный id", http.StatusBadRequest)
|
||||
// Невалидный id = несуществующая сущность; в БД не ходим.
|
||||
http.Error(w, "задача не найдена", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
@@ -145,36 +147,37 @@ func (s *server) handleApply(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *server) handleRefine(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) 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 {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) 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 {
|
||||
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) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) 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 {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
_ = r.ParseForm()
|
||||
candidateID, err := strconv.ParseInt(r.PostForm.Get("candidate_id"), 10, 64)
|
||||
// Входная граница: id кандидата из формы валидируется как ULID.
|
||||
candidateID, err := ident.Parse(r.PostForm.Get("candidate_id"))
|
||||
if err != nil {
|
||||
return errInvalidCandidate
|
||||
}
|
||||
@@ -183,14 +186,14 @@ func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) 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 {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
return s.deps.Reviewer.ClearProvider(ctx, id)
|
||||
})
|
||||
}
|
||||
@@ -240,7 +243,7 @@ func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// reviewAction — общий помощник: выполнить действие и вернуться на страницу
|
||||
// ревью (с ошибкой в ?err при неудаче).
|
||||
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) error) {
|
||||
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
redirectErr(w, r, "некорректный id")
|
||||
@@ -296,8 +299,8 @@ func providerURL(provider, id, mediaType string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func redirectReview(w http.ResponseWriter, r *http.Request, id int64, msg string) {
|
||||
u := "/review/" + strconv.FormatInt(id, 10)
|
||||
func redirectReview(w http.ResponseWriter, r *http.Request, id string, msg string) {
|
||||
u := "/review/" + id
|
||||
if msg != "" {
|
||||
u += "?err=" + url.QueryEscape(msg)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user