Единое окно: полное пользовательское удаление загрузки (delete)

Вторая половина «единого окна»: команда «Удалить» снимает наши библиотечные
хардлинки (гард последней копии осознанно выключен, в отличие от Undo) и сносит
раздачу с файлами из qBittorrent (deleteFiles=true) → терминальный deleted.
Доступна из done/orphaned/target_missing, идемпотентна к отсутствующей стороне;
инициатор различается через error_code=user_delete. Подтверждение обязательно:
веб — danger-секция внизу страницы (hx-confirm + details), Telegram — двухшаговый
inline-confirm. qbt.Delete + layout.Remove (unlink без ErrLastCopy, только свои
ссылки под movies/series). Граф переходов не менялся — рёбра уже были.

OpenSpec: state-reconciliation +1 требование; синк workflow.md; беклог закрыт.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-10 12:17:51 +03:00
co-authored by Claude Opus 4.8
parent e68f96df9e
commit 3f1a928000
25 changed files with 893 additions and 76 deletions
+50
View File
@@ -256,6 +256,56 @@ func (c *Client) Add(ctx context.Context, ar AddRequest) error {
return nil
}
// Delete удаляет раздачу(и) из qBittorrent. deleteFiles=true сносит и файлы на
// диске (освобождая место — библиотечный хардлинк jellybit к тому же иноду
// снимается отдельно). Идемпотентно: неизвестный хеш qBittorrent просто
// игнорирует, ошибкой это не считаем. jellybit сам файлы под downloads не
// трогает — их удаляет qBittorrent по этому вызову.
func (c *Client) Delete(ctx context.Context, hashes []string, deleteFiles bool) error {
nonEmpty := hashes[:0:0]
for _, h := range hashes {
if h = strings.TrimSpace(h); h != "" {
nonEmpty = append(nonEmpty, h)
}
}
if len(nonEmpty) == 0 {
return fmt.Errorf("qbittorrent delete: no hashes")
}
form := url.Values{
"hashes": {strings.Join(nonEmpty, "|")},
"deleteFiles": {strconv.FormatBool(deleteFiles)},
}
body := form.Encode()
log := logctx.FromOr(ctx, c.log)
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/delete", Start: time.Now()}
resp, err := c.do(ctx, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.endpoint("/api/v2/torrents/delete"), strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", c.base.String())
return req, nil
})
if err != nil {
call.Failure(log, err)
return fmt.Errorf("qbittorrent delete: %w", err)
}
defer func() { _ = resp.Body.Close() }()
call.Status = resp.StatusCode
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
err := fmt.Errorf("qbittorrent delete: status %d body %q",
resp.StatusCode, strings.TrimSpace(string(respBody)))
call.Failure(log, err)
return err
}
call.Success(log, "hashes", len(nonEmpty), "delete_files", deleteFiles)
return nil
}
// Torrents возвращает задачи указанной категории (пустая — все).
func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) {
log := logctx.FromOr(ctx, c.log)
+32
View File
@@ -114,6 +114,38 @@ func TestAddSendsRename(t *testing.T) {
}
}
func TestDeleteSendsHashesAndDeleteFiles(t *testing.T) {
var gotHashes, gotDeleteFiles string
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/torrents/delete", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
gotHashes = r.PostForm.Get("hashes")
gotDeleteFiles = r.PostForm.Get("deleteFiles")
_, _ = w.Write([]byte("Ok."))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := newClient(t, srv.URL)
err := c.Delete(context.Background(), []string{"aaa", "", "bbb"}, true)
if err != nil {
t.Fatalf("Delete: %v", err)
}
if gotHashes != "aaa|bbb" { // пустые хеши отфильтрованы, склейка через |
t.Errorf("hashes = %q, want %q", gotHashes, "aaa|bbb")
}
if gotDeleteFiles != "true" {
t.Errorf("deleteFiles = %q, want true", gotDeleteFiles)
}
}
func TestDeleteNoHashesIsError(t *testing.T) {
c := newClient(t, "http://unused")
if err := c.Delete(context.Background(), []string{"", " "}, true); err == nil {
t.Fatal("Delete with no hashes must error before any request")
}
}
func TestAddPerformsLazyLogin(t *testing.T) {
srv := fakeQBittorrent(t, "[]")
c := newClient(t, srv.URL)