Единое окно: полное пользовательское удаление загрузки (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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user