Skip to content

feat(downloads): unlink imported files once *arr confirms the import - #79

Merged
roziscoding merged 2 commits into
mainfrom
feat/unlink-imported-files
Aug 1, 2026
Merged

feat(downloads): unlink imported files once *arr confirms the import#79
roziscoding merged 2 commits into
mainfrom
feat/unlink-imported-files

Conversation

@roziscoding

Copy link
Copy Markdown
Owner

What does this PR do?

Adds downloads.unlinkImportedFiles (default false): once an *arr confirms it imported a download, jack drops its own copy from completedPath.

The motivation is that jack has no use for the file after the import — it's never re-served or re-imported. Whether the *arr hardlinks (my setup) or copies (a friend's), the library has what it needs, so jack's copy is dead weight.

It is a plain unlink of that one file, never a recursive delete:

  • Hardlink — the library's link keeps the data alive; only jack's extra directory entry disappears.
  • Copy/move — the library owns its own bytes, so removing jack's copy just frees the space.

Only ever on a confirmed import

ImportWatcher.cleanUpImportedFile runs immediately after markImported, at exactly the two points where *arr confirms the import:

  1. the destination reports the infohash in its import history, or
  2. the tracked manual-import command reports completed.

Rows that are queued, still importing, failed, or blackhole keep their file. A failed unlink is logged and leaves the row imported — the import already succeeded, so cleanup never fails it.

Shared path guards

The guards that were inline in DownloadsService.delete move into unlinkDownloadArtifact (modules/downloads/artifact-cleanup.ts) and both call sites now use it: the path must resolve inside completedPath, the live row must still reference it, no sibling row may reference it, and ENOENT counts as success. All three are re-evaluated immediately before the syscall.

Configurable from the UI

New GET/PATCH /config/downloads on the management API, and a Settings → Downloads section with a switch. The PATCH is merged into the stored block, so the file's other download knobs are never clobbered (a first patch that would leave the merged block without completedPath is rejected with a 400). The watcher reads the flag per import rather than at boot, so the toggle applies without a restart — unlike every other key in the block. When the config has no downloads block at all, the section renders an explanatory empty state instead of a dead toggle.

Related issue

Checklist

  • Commits follow Conventional Commits
  • Commits are signed (GitHub enforces this — unsigned commits are rejected)
  • mise run lint:fix was run and its output is committed (no changes)
  • mise run test passes — 464 pass / 0 fail. mise run test:e2e was not run: no Docker in the dev environment. The default is false, so the import path is byte-for-byte unchanged unless the option is enabled.
  • Docs updated if behavior or configuration changed — website/reference/configuration.md, website/guide/management-ui.md, examples/config.jsonc

Tests

12 new tests:

  • unlinks on both confirmation paths (history hash, completed manual-import command)
  • keeps the file while queued, when the import fails, and when the option is off
  • reads the option per import (toggle applies without a restart)
  • leaves the row imported when the unlink itself fails
  • never unlinks a path outside completedPath
  • GET/PATCH /config/downloads: merge semantics, 400 on a merged block with no completedPath, 400 on a non-boolean flag, 404 without a ConfigService

Also smoke-tested against a running backend: GETPATCHGET round-trips and lands correctly on disk.

Adds `downloads.unlinkImportedFiles` (default false). When enabled, the import
watcher drops jack's copy in completedPath right after a row flips to `imported`
— only on a confirmed import (the destination reports the hash in its history, or
the manual-import command reports completed), never on a queued or failed row.

It is a plain unlink of that one file: if *arr hardlinked into the library the
data lives on through the library's link; if it copied, only jack's copy goes.

The path guards from DownloadsService.delete move into a shared
`unlinkDownloadArtifact` (inside completedPath, still owned by the live row, not
shared with a sibling, ENOENT-tolerant) and both call sites use it.

Configurable from the management UI (Settings -> Downloads) via a new
GET/PATCH /config/downloads; the PATCH merges into the stored block and the
watcher reads the flag per import, so the toggle applies without a restart.
@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds optional post-import artifact cleanup and management controls.

  • Introduces downloads.unlinkImportedFiles, disabled by default.
  • Unlinks completed artifacts only after confirmed imports while preserving files needed by active, queued, or failed sibling rows.
  • Adds management API endpoints, a Settings toggle, tests, examples, and documentation.

Confidence Score: 5/5

The PR appears safe to merge.

The previously reported shared-row cleanup deadlock is resolved because imported siblings no longer block the last importing row from unlinking the shared artifact, while rows that can still require the file continue to protect it.

Reviews (2): Last reviewed commit: "fix(downloads): let the last row sharing..." | Re-trigger Greptile

Comment thread apps/backend/src/modules/downloads/import-watcher.ts
The sibling guard blocked an unlink whenever any other row referenced the path,
including rows that were themselves already imported. Two rows can legitimately
share a destination — the in-flight duplicate guard only covers active transfers,
so a re-grab that lands while the first row is still import_queued gets its own
row on the same path. Both would then import, each defer to the other, and since
`imported` is terminal and never revisited, the file outlived both rows despite
unlinkImportedFiles being on.

A sibling now only blocks while it still has a use for the file: downloading into
it, awaiting import, or holding a failed import that a retry would re-trigger
against that path. `imported` is the one status that doesn't qualify, so the row
that imports last does the cleanup.

This also applies to the delete path, which shares the helper: deleting a row now
removes a shared file that only already-imported rows still point at.

Reported by Greptile on #79.
@roziscoding
roziscoding merged commit 26c22db into main Aug 1, 2026
10 checks passed
@roziscoding
roziscoding deleted the feat/unlink-imported-files branch August 1, 2026 00:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant