Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/2046-remove-torrent-on-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Added
- **Remove a torrent from the download client after it imports** (#2046): a new per client toggle, Remove torrent after import, for qBittorrent and Transmission. It is off by default. When on, Bindery removes the finished torrent from the client once the book is imported and leaves the downloaded files on disk, so a hard linked or copied import is unaffected. Leave it off on private trackers where you need to keep seeding. Deluge and rTorrent do not offer it yet. Thanks ccarpinteri for building it.
39 changes: 24 additions & 15 deletions internal/db/download_clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type DownloadClientRepo struct {

const downloadClientSelectColumns = `
id, name, type, host, port, api_key, use_ssl, url_base, username, password,
category, category_audiobook, path_remap, priority, enabled, created_at, updated_at`
category, category_audiobook, path_remap, priority, enabled, remove_on_import, created_at, updated_at`

// torrentClientTypes and usenetClientTypes are the download-client types each
// protocol can be served by. They back the protocol-scoped queries below; a new
Expand Down Expand Up @@ -142,13 +142,15 @@ func (r *DownloadClientRepo) List(ctx context.Context) ([]models.DownloadClient,
for rows.Next() {
var c models.DownloadClient
var enabled, useSSL int
var removeOnImport int
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Host, &c.Port, &c.APIKey,
&useSSL, &c.URLBase, &c.Username, &c.Password, &c.Category, &c.CategoryAudiobook, &c.PathRemap, &c.Priority,
&enabled, &c.CreatedAt, &c.UpdatedAt); err != nil {
&enabled, &removeOnImport, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
c.Enabled = enabled == 1
c.UseSSL = useSSL == 1
c.RemoveOnImport = removeOnImport == 1
hydrateClientCredentials(&c)
clients = append(clients, c)
}
Expand All @@ -157,13 +159,13 @@ func (r *DownloadClientRepo) List(ctx context.Context) ([]models.DownloadClient,

func (r *DownloadClientRepo) GetByID(ctx context.Context, id int64) (*models.DownloadClient, error) {
var c models.DownloadClient
var enabled, useSSL int
var enabled, useSSL, removeOnImport int
err := r.db.QueryRowContext(ctx, `
SELECT `+downloadClientSelectColumns+`
FROM download_clients WHERE id=?`, id).
Scan(&c.ID, &c.Name, &c.Type, &c.Host, &c.Port, &c.APIKey,
&useSSL, &c.URLBase, &c.Username, &c.Password, &c.Category, &c.CategoryAudiobook, &c.PathRemap, &c.Priority,
&enabled, &c.CreatedAt, &c.UpdatedAt)
&enabled, &removeOnImport, &c.CreatedAt, &c.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
Expand All @@ -172,6 +174,7 @@ func (r *DownloadClientRepo) GetByID(ctx context.Context, id int64) (*models.Dow
}
c.Enabled = enabled == 1
c.UseSSL = useSSL == 1
c.RemoveOnImport = removeOnImport == 1
hydrateClientCredentials(&c)
return &c, nil
}
Expand All @@ -190,13 +193,15 @@ func (r *DownloadClientRepo) ListEnabled(ctx context.Context) ([]models.Download
for rows.Next() {
var c models.DownloadClient
var enabled, useSSL int
var removeOnImport int
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Host, &c.Port, &c.APIKey,
&useSSL, &c.URLBase, &c.Username, &c.Password, &c.Category, &c.CategoryAudiobook, &c.PathRemap, &c.Priority,
&enabled, &c.CreatedAt, &c.UpdatedAt); err != nil {
&enabled, &removeOnImport, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
c.Enabled = enabled == 1
c.UseSSL = useSSL == 1
c.RemoveOnImport = removeOnImport == 1
hydrateClientCredentials(&c)
clients = append(clients, c)
}
Expand All @@ -205,13 +210,13 @@ func (r *DownloadClientRepo) ListEnabled(ctx context.Context) ([]models.Download

func (r *DownloadClientRepo) GetFirstEnabled(ctx context.Context) (*models.DownloadClient, error) {
var c models.DownloadClient
var enabled, useSSL int
var enabled, useSSL, removeOnImport int
err := r.db.QueryRowContext(ctx, `
SELECT `+downloadClientSelectColumns+`
FROM download_clients WHERE enabled=1 ORDER BY priority LIMIT 1`).
Scan(&c.ID, &c.Name, &c.Type, &c.Host, &c.Port, &c.APIKey,
&useSSL, &c.URLBase, &c.Username, &c.Password, &c.Category, &c.CategoryAudiobook, &c.PathRemap, &c.Priority,
&enabled, &c.CreatedAt, &c.UpdatedAt)
&enabled, &removeOnImport, &c.CreatedAt, &c.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
Expand All @@ -220,6 +225,7 @@ func (r *DownloadClientRepo) GetFirstEnabled(ctx context.Context) (*models.Downl
}
c.Enabled = enabled == 1
c.UseSSL = useSSL == 1
c.RemoveOnImport = removeOnImport == 1
hydrateClientCredentials(&c)
return &c, nil
}
Expand All @@ -229,14 +235,14 @@ func (r *DownloadClientRepo) GetFirstEnabled(ctx context.Context) (*models.Downl
// Returns (nil, nil) if no matching client is configured.
func (r *DownloadClientRepo) GetFirstEnabledByProtocol(ctx context.Context, protocol string) (*models.DownloadClient, error) {
var c models.DownloadClient
var enabled, useSSL int
var enabled, useSSL, removeOnImport int
placeholders, args := clientTypesForProtocol(protocol)
err := r.db.QueryRowContext(ctx, `
SELECT `+downloadClientSelectColumns+`
FROM download_clients WHERE enabled=1 AND type IN (`+placeholders+`) ORDER BY priority LIMIT 1`, args...).
Scan(&c.ID, &c.Name, &c.Type, &c.Host, &c.Port, &c.APIKey,
&useSSL, &c.URLBase, &c.Username, &c.Password, &c.Category, &c.CategoryAudiobook, &c.PathRemap, &c.Priority,
&enabled, &c.CreatedAt, &c.UpdatedAt)
&enabled, &removeOnImport, &c.CreatedAt, &c.UpdatedAt)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
Expand All @@ -245,6 +251,7 @@ func (r *DownloadClientRepo) GetFirstEnabledByProtocol(ctx context.Context, prot
}
c.Enabled = enabled == 1
c.UseSSL = useSSL == 1
c.RemoveOnImport = removeOnImport == 1
hydrateClientCredentials(&c)
return &c, nil
}
Expand All @@ -266,13 +273,15 @@ func (r *DownloadClientRepo) GetEnabledByProtocol(ctx context.Context, protocol
for rows.Next() {
var c models.DownloadClient
var enabled, useSSL int
var removeOnImport int
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Host, &c.Port, &c.APIKey,
&useSSL, &c.URLBase, &c.Username, &c.Password, &c.Category, &c.CategoryAudiobook, &c.PathRemap, &c.Priority,
&enabled, &c.CreatedAt, &c.UpdatedAt); err != nil {
&enabled, &removeOnImport, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
c.Enabled = enabled == 1
c.UseSSL = useSSL == 1
c.RemoveOnImport = removeOnImport == 1
hydrateClientCredentials(&c)
clients = append(clients, c)
}
Expand All @@ -283,9 +292,9 @@ func (r *DownloadClientRepo) Create(ctx context.Context, c *models.DownloadClien
normalizeClientCredentialStorage(c)
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
INSERT INTO download_clients (name, type, host, port, api_key, use_ssl, url_base, username, password, category, category_audiobook, path_remap, priority, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.Name, c.Type, c.Host, c.Port, c.APIKey, c.UseSSL, c.URLBase, c.Username, c.Password, c.Category, c.CategoryAudiobook, c.PathRemap, c.Priority, c.Enabled, now, now)
INSERT INTO download_clients (name, type, host, port, api_key, use_ssl, url_base, username, password, category, category_audiobook, path_remap, priority, enabled, remove_on_import, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.Name, c.Type, c.Host, c.Port, c.APIKey, c.UseSSL, c.URLBase, c.Username, c.Password, c.Category, c.CategoryAudiobook, c.PathRemap, c.Priority, c.Enabled, c.RemoveOnImport, now, now)
if err != nil {
return fmt.Errorf("create download client: %w", err)
}
Expand All @@ -304,9 +313,9 @@ func (r *DownloadClientRepo) Update(ctx context.Context, c *models.DownloadClien
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx, `
UPDATE download_clients SET name=?, type=?, host=?, port=?, api_key=?, use_ssl=?,
url_base=?, username=?, password=?, category=?, category_audiobook=?, path_remap=?, priority=?, enabled=?, updated_at=?
url_base=?, username=?, password=?, category=?, category_audiobook=?, path_remap=?, priority=?, enabled=?, remove_on_import=?, updated_at=?
WHERE id=?`,
c.Name, c.Type, c.Host, c.Port, c.APIKey, c.UseSSL, c.URLBase, c.Username, c.Password, c.Category, c.CategoryAudiobook, c.PathRemap, c.Priority, c.Enabled, now, c.ID)
c.Name, c.Type, c.Host, c.Port, c.APIKey, c.UseSSL, c.URLBase, c.Username, c.Password, c.Category, c.CategoryAudiobook, c.PathRemap, c.Priority, c.Enabled, c.RemoveOnImport, now, c.ID)
return err
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE download_clients ADD COLUMN remove_on_import INTEGER NOT NULL DEFAULT 0;
5 changes: 5 additions & 0 deletions internal/importer/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,11 @@ func (s *Scanner) tryImportInternal(ctx context.Context, dl *models.Download, do
// Walk: importPending → importing → imported.
s.updateDownloadStatus(ctx, dl.ID, models.StateImporting)
s.updateDownloadStatus(ctx, dl.ID, models.StateImported)
if cleanupFunc != nil {
if err := cleanupFunc(); err != nil {
slog.Warn("cleanup failed", cleanupWarnAttrs(cleanupClientType, cleanupRemoteID, err)...)
}
}
return
}
// Distinguish "path doesn't exist on this host" from "path exists but has
Expand Down
7 changes: 5 additions & 2 deletions internal/importer/scanner_client_wrappers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"path/filepath"
"testing"

"github.com/vavallee/bindery/internal/downloader"
"github.com/vavallee/bindery/internal/downloader/nzbget"
"github.com/vavallee/bindery/internal/downloader/sabnzbd"
"github.com/vavallee/bindery/internal/models"
Expand Down Expand Up @@ -55,8 +56,10 @@ func TestTryImportTransmission_Delegates(t *testing.T) {
Title: "Transmission Delegate Test",
Status: models.StateCompleted,
}
// Transmission wrapper passes nil cleanupFunc; empty dir → fails fast.
s.tryImportTransmission(ctx, dl, t.TempDir(), nil)
client := &models.DownloadClient{Type: "transmission"}
trans := downloader.TransmissionFor(client)
// RemoveOnImport left unset (false) → nil cleanupFunc; empty dir → fails fast.
s.tryImportTransmission(ctx, trans, client, dl, t.TempDir(), nil)
}

// TestTryImportNZBGet_CleanupCalledOnSuccess verifies that the cleanup closure
Expand Down
Loading
Loading