Skip to content

Commit 26c22db

Browse files
authored
feat(downloads): unlink imported files once *arr confirms the import (#79)
* feat(downloads): unlink imported files once *arr confirms the import 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. * fix(downloads): let the last row sharing a destination clean it up 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.
1 parent 2f838ac commit 26c22db

17 files changed

Lines changed: 663 additions & 31 deletions

‎apps/backend/src/__tests__/config-management.test.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,3 +586,75 @@ describe('Management API jack config', () => {
586586
expect(res.status).toBe(404)
587587
})
588588
})
589+
590+
describe('Management API downloads config', () => {
591+
// Seed a config file that already carries a downloads block, so the partial-patch
592+
// behaviour (merge onto what's stored) has something to merge onto.
593+
async function withDownloadsBlock(app: ReturnType<typeof mgmtApp>, path: string) {
594+
const res = await app.request('/config/downloads', {
595+
method: 'PATCH',
596+
headers: { ...KEY, 'Content-Type': 'application/json' },
597+
body: JSON.stringify({ completedPath: '/downloads', maxConcurrentDownloads: 5 }),
598+
})
599+
expect(res.status).toBe(200)
600+
return path
601+
}
602+
603+
test('GET /config/downloads returns null when no downloads block is configured', async () => {
604+
const { app } = await makeMutableApp()
605+
const res = await app.request('/config/downloads', { headers: KEY })
606+
expect(res.status).toBe(200)
607+
expect(await res.json()).toBeNull()
608+
})
609+
610+
test('PATCH merges into the stored block instead of replacing it', async () => {
611+
const { app, path } = await makeMutableApp()
612+
await withDownloadsBlock(app, path)
613+
614+
const res = await app.request('/config/downloads', {
615+
method: 'PATCH',
616+
headers: { ...KEY, 'Content-Type': 'application/json' },
617+
body: JSON.stringify({ unlinkImportedFiles: true }),
618+
})
619+
expect(res.status).toBe(200)
620+
621+
// The unrelated knobs survive, and only what was set is written — defaults are
622+
// never baked into the file.
623+
const onDisk = jsonc.parse(await Bun.file(path).text()) as { downloads: Record<string, unknown> }
624+
expect(onDisk.downloads).toEqual({ completedPath: '/downloads', maxConcurrentDownloads: 5, unlinkImportedFiles: true })
625+
626+
const get = await app.request('/config/downloads', { headers: KEY })
627+
expect(await get.json()).toMatchObject({ unlinkImportedFiles: true, maxConcurrentDownloads: 5 })
628+
})
629+
630+
test('PATCH rejects a first patch that leaves the merged block without completedPath', async () => {
631+
const { app } = await makeMutableApp()
632+
const res = await app.request('/config/downloads', {
633+
method: 'PATCH',
634+
headers: { ...KEY, 'Content-Type': 'application/json' },
635+
body: JSON.stringify({ unlinkImportedFiles: true }),
636+
})
637+
expect(res.status).toBe(400)
638+
})
639+
640+
test('PATCH with a non-boolean unlinkImportedFiles returns 400', async () => {
641+
const { app, path } = await makeMutableApp()
642+
await withDownloadsBlock(app, path)
643+
644+
const res = await app.request('/config/downloads', {
645+
method: 'PATCH',
646+
headers: { ...KEY, 'Content-Type': 'application/json' },
647+
body: JSON.stringify({ unlinkImportedFiles: 'yes' }),
648+
})
649+
expect(res.status).toBe(400)
650+
})
651+
652+
test('PATCH /config/downloads is unregistered (404) without a ConfigService', async () => {
653+
const res = await mgmtApp().request('/config/downloads', {
654+
method: 'PATCH',
655+
headers: { ...KEY, 'Content-Type': 'application/json' },
656+
body: JSON.stringify({ unlinkImportedFiles: true }),
657+
})
658+
expect(res.status).toBe(404)
659+
})
660+
})

‎apps/backend/src/__tests__/downloads-service.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function downloadsConfig(overrides: Partial<Record<string, number>> = {}) {
4343
maxManualImportAttempts: 6,
4444
manualImportBackoffBaseMs: 60_000,
4545
manualImportBackoffMaxMs: 1_800_000,
46+
unlinkImportedFiles: false,
4647
...overrides,
4748
}
4849
}

‎apps/backend/src/__tests__/import-watcher.test.ts‎

Lines changed: 242 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { ArrServerConnector, ManualImportParams } from '../lib/servers/arr/base'
22
import type { DownloadsRepository } from '../modules/downloads/downloads.repository'
3-
import { dirname } from 'node:path'
3+
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
4+
import { tmpdir } from 'node:os'
5+
import { dirname, join } from 'node:path'
46
import { Database } from 'bun:sqlite'
5-
import { describe, expect, mock, test } from 'bun:test'
7+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
68
import { drizzle } from 'drizzle-orm/bun-sqlite'
79
import { runMigrations } from '../database/connection'
810
import * as schema from '../database/schema'
@@ -414,3 +416,241 @@ describe('ImportWatcher tracked manual imports', () => {
414416
expect(repo.get(row.id)).toMatchObject({ status: 'failed', error: 'episode ids could not be resolved' })
415417
})
416418
})
419+
420+
// `downloads.unlinkImportedFiles`: once *arr confirms the import, jack's own copy in
421+
// completedPath is dead weight (*arr hardlinked or copied it into the library), so the
422+
// watcher drops jack's link. Only ever on a confirmed import — never on a queued,
423+
// failed, or still-pending row.
424+
describe('ImportWatcher imported-file cleanup', () => {
425+
let completedPath: string
426+
427+
beforeEach(async () => {
428+
completedPath = await mkdtemp(join(tmpdir(), 'jack-unlink-'))
429+
})
430+
431+
afterEach(async () => {
432+
await rm(completedPath, { recursive: true, force: true })
433+
})
434+
435+
// A queued row whose destPath is a real file inside the temp completedPath.
436+
async function queuedRowWithFile(repo: DownloadsRepository, opts: { name?: string, mode?: 'jack_manual' } = {}) {
437+
const destPath = join(completedPath, opts.name ?? 'x.mkv')
438+
await Bun.write(destPath, 'payload')
439+
const row = repo.create({
440+
torrentFilename: 't.torrent',
441+
peerId: 'peer-1',
442+
peerName: 'Friend',
443+
itemId: 'movie:1',
444+
filename: release.filename,
445+
destPath,
446+
partPath: `${destPath}.part`,
447+
releaseSize: release.size,
448+
release,
449+
qbSourceServer: 'My Radarr',
450+
sourceServerId: 'radarr-1',
451+
...(opts.mode === 'jack_manual'
452+
? { importMode: 'jack_manual' as const, importTarget: { kind: 'movie' as const, movieId: 42 } }
453+
: {}),
454+
})
455+
repo.markImportQueued(row.id)
456+
return row
457+
}
458+
459+
function cleanupWatcher(repo: DownloadsRepository, servers: ImportWatcherServer[], enabled: () => boolean) {
460+
return new ImportWatcher(repo, { servers: servers as unknown as ArrServerConnector[] }, 1000, undefined, undefined, {
461+
enabled,
462+
completedPath,
463+
})
464+
}
465+
466+
test('unlinks the file once *arr history confirms the import', async () => {
467+
const repo = makeRepo()
468+
const row = await queuedRowWithFile(repo)
469+
const watcher = cleanupWatcher(repo, [{ ...fakeServer('My Radarr', [HASH]), id: 'radarr-1' }], () => true)
470+
471+
expect(await watcher.tick()).toBe(1)
472+
expect(repo.get(row.id)?.status).toBe('imported')
473+
expect(await Bun.file(row.destPath).exists()).toBe(false)
474+
})
475+
476+
test('unlinks the file once a tracked manual import command completes', async () => {
477+
const repo = makeRepo()
478+
const row = await queuedRowWithFile(repo, { mode: 'jack_manual' })
479+
const server: ImportWatcherServer = {
480+
id: 'radarr-1',
481+
name: 'My Radarr',
482+
isInitialized: true,
483+
recentlyImportedDownloadIds: async () => new Set(),
484+
manualImport: async () => 44,
485+
manualImportCommandStatus: async () => ({ state: 'completed' }),
486+
}
487+
const watcher = cleanupWatcher(repo, [server], () => true)
488+
489+
// First tick pushes the manual import, second sees the command completed.
490+
await watcher.tick()
491+
expect(await Bun.file(row.destPath).exists()).toBe(true)
492+
await watcher.tick()
493+
494+
expect(repo.get(row.id)?.status).toBe('imported')
495+
expect(await Bun.file(row.destPath).exists()).toBe(false)
496+
})
497+
498+
test('keeps the file while the row is still queued, and when the import fails', async () => {
499+
const repo = makeRepo()
500+
const queued = await queuedRowWithFile(repo, { name: 'queued.mkv', mode: 'jack_manual' })
501+
const failing = await queuedRowWithFile(repo, { name: 'failing.mkv', mode: 'jack_manual' })
502+
const server: ImportWatcherServer = {
503+
id: 'radarr-1',
504+
name: 'My Radarr',
505+
isInitialized: true,
506+
recentlyImportedDownloadIds: async () => new Set(),
507+
manualImport: async params => (params.paths[0] === failing.destPath ? 46 : 45),
508+
manualImportCommandStatus: async commandId =>
509+
(commandId === 46 ? { state: 'failed', error: 'arr rejected it' } : { state: 'pending' }),
510+
}
511+
const watcher = cleanupWatcher(repo, [server], () => true)
512+
513+
await watcher.tick()
514+
await watcher.tick()
515+
516+
expect(repo.get(queued.id)?.status).toBe('import_queued')
517+
expect(repo.get(failing.id)?.status).toBe('failed')
518+
expect(await Bun.file(queued.destPath).exists()).toBe(true)
519+
expect(await Bun.file(failing.destPath).exists()).toBe(true)
520+
})
521+
522+
test('keeps the file when the option is off', async () => {
523+
const repo = makeRepo()
524+
const row = await queuedRowWithFile(repo)
525+
const watcher = cleanupWatcher(repo, [{ ...fakeServer('My Radarr', [HASH]), id: 'radarr-1' }], () => false)
526+
527+
expect(await watcher.tick()).toBe(1)
528+
expect(repo.get(row.id)?.status).toBe('imported')
529+
expect(await Bun.file(row.destPath).exists()).toBe(true)
530+
})
531+
532+
test('reads the option per import, so toggling it applies without a restart', async () => {
533+
const repo = makeRepo()
534+
const kept = await queuedRowWithFile(repo, { name: 'kept.mkv' })
535+
let enabled = false
536+
const watcher = cleanupWatcher(repo, [{ ...fakeServer('My Radarr', [HASH]), id: 'radarr-1' }], () => enabled)
537+
538+
await watcher.tick()
539+
expect(await Bun.file(kept.destPath).exists()).toBe(true)
540+
541+
enabled = true
542+
const unlinked = await queuedRowWithFile(repo, { name: 'unlinked.mkv' })
543+
await watcher.tick()
544+
545+
expect(await Bun.file(unlinked.destPath).exists()).toBe(false)
546+
})
547+
548+
test('leaves the row imported when the unlink fails', async () => {
549+
const repo = makeRepo()
550+
const row = await queuedRowWithFile(repo)
551+
// A directory where the file is expected: unlink(2) fails with EISDIR/EPERM.
552+
await rm(row.destPath)
553+
await mkdir(row.destPath)
554+
const watcher = cleanupWatcher(repo, [{ ...fakeServer('My Radarr', [HASH]), id: 'radarr-1' }], () => true)
555+
556+
expect(await watcher.tick()).toBe(1)
557+
expect(repo.get(row.id)?.status).toBe('imported')
558+
})
559+
560+
// Two rows can share a destination: the in-flight duplicate guard only covers active
561+
// transfers, so a re-grab that lands while the first row is still import_queued gets
562+
// its own row on the same path. Whichever imports last has to do the cleanup —
563+
// otherwise both defer to each other and the file outlives both rows.
564+
test('defers cleanup to the last of two rows sharing a destination', async () => {
565+
const repo = makeRepo()
566+
const first = await queuedRowWithFile(repo, { name: 'shared.mkv' })
567+
const second = repo.create({
568+
torrentFilename: 't2.torrent',
569+
peerId: 'peer-1',
570+
peerName: 'Friend',
571+
itemId: 'movie:1',
572+
filename: release.filename,
573+
destPath: first.destPath,
574+
partPath: first.partPath,
575+
releaseSize: release.size,
576+
release,
577+
qbSourceServer: 'My Radarr',
578+
sourceServerId: 'radarr-1',
579+
})
580+
repo.markImportQueued(second.id)
581+
582+
// Only the first row's hash is in *arr history to begin with, so it imports alone.
583+
const importedHashes = [HASH]
584+
const server = {
585+
id: 'radarr-1',
586+
name: 'My Radarr',
587+
isInitialized: true,
588+
recentlyImportedDownloadIds: async () => new Set(importedHashes.map(h => h.toLowerCase())),
589+
} as any
590+
const watcher = cleanupWatcher(repo, [server], () => true)
591+
592+
await watcher.tick()
593+
594+
// Both rows flip on the same hash, so drive them one at a time by re-reading state.
595+
const imported = [first, second].filter(row => repo.get(row.id)?.status === 'imported')
596+
const stillQueued = [first, second].filter(row => repo.get(row.id)?.status === 'import_queued')
597+
expect(imported).toHaveLength(2)
598+
expect(stillQueued).toHaveLength(0)
599+
// The row that imported second saw an already-imported sibling and cleaned up.
600+
expect(await Bun.file(first.destPath).exists()).toBe(false)
601+
})
602+
603+
test('keeps the file while a row sharing the destination is still awaiting import', async () => {
604+
const repo = makeRepo()
605+
const importing = await queuedRowWithFile(repo, { name: 'shared.mkv' })
606+
// A second row on the same path that *arr has not imported — it still needs the file.
607+
const waiting = repo.create({
608+
torrentFilename: 't2.torrent',
609+
peerId: 'peer-1',
610+
peerName: 'Friend',
611+
itemId: 'movie:2',
612+
filename: release.filename,
613+
destPath: importing.destPath,
614+
partPath: importing.partPath,
615+
releaseSize: release.size,
616+
release,
617+
qbSourceServer: 'Other Radarr',
618+
sourceServerId: 'radarr-2',
619+
})
620+
repo.markImportQueued(waiting.id)
621+
622+
// Only radarr-1 is connected, so only `importing` can flip to imported this tick.
623+
const watcher = cleanupWatcher(repo, [{ ...fakeServer('My Radarr', [HASH]), id: 'radarr-1' }], () => true)
624+
625+
expect(await watcher.tick()).toBe(1)
626+
expect(repo.get(importing.id)?.status).toBe('imported')
627+
expect(repo.get(waiting.id)?.status).toBe('import_queued')
628+
expect(await Bun.file(importing.destPath).exists()).toBe(true)
629+
})
630+
631+
test('never unlinks a path outside completedPath', async () => {
632+
const repo = makeRepo()
633+
const outside = await mkdtemp(join(tmpdir(), 'jack-outside-'))
634+
const destPath = join(outside, 'x.mkv')
635+
await Bun.write(destPath, 'payload')
636+
const row = repo.create({
637+
torrentFilename: 't.torrent',
638+
peerId: 'peer-1',
639+
peerName: 'Friend',
640+
itemId: 'movie:1',
641+
filename: release.filename,
642+
destPath,
643+
partPath: `${destPath}.part`,
644+
releaseSize: release.size,
645+
release,
646+
qbSourceServer: 'My Radarr',
647+
sourceServerId: 'radarr-1',
648+
})
649+
repo.markImportQueued(row.id)
650+
const watcher = cleanupWatcher(repo, [{ ...fakeServer('My Radarr', [HASH]), id: 'radarr-1' }], () => true)
651+
652+
expect(await watcher.tick()).toBe(1)
653+
expect(await Bun.file(destPath).exists()).toBe(true)
654+
await rm(outside, { recursive: true, force: true })
655+
})
656+
})

‎apps/backend/src/index.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,18 @@ logger.info({
6969

7070
// Detect *arr imports of finished downloads and flip them import_queued → imported.
7171
// Construct this before the management app so failed imports can be retried there.
72-
const importWatcher = config.downloads
73-
? new ImportWatcher(downloadsRepository, connectorManager, config.downloads.importPollIntervalMs, {
74-
maxAttempts: config.downloads.maxManualImportAttempts,
75-
backoffBaseMs: config.downloads.manualImportBackoffBaseMs,
76-
backoffMaxMs: config.downloads.manualImportBackoffMaxMs,
72+
const downloadsConfig = config.downloads
73+
const importWatcher = downloadsConfig
74+
? new ImportWatcher(downloadsRepository, connectorManager, downloadsConfig.importPollIntervalMs, {
75+
maxAttempts: downloadsConfig.maxManualImportAttempts,
76+
backoffBaseMs: downloadsConfig.manualImportBackoffBaseMs,
77+
backoffMaxMs: downloadsConfig.manualImportBackoffMaxMs,
78+
}, undefined, {
79+
// Read per import, from the file when the management API can rewrite it, so
80+
// toggling it in the UI applies without a restart. completedPath still comes
81+
// from the boot config — it's the root every unlink is confined to.
82+
enabled: () => configService?.getRawDownloads()?.unlinkImportedFiles ?? downloadsConfig.unlinkImportedFiles,
83+
completedPath: downloadsConfig.completedPath,
7784
})
7885
: undefined
7986

‎apps/backend/src/lib/config.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,34 @@ export const DownloadsConfig = z.object({
189189
maxManualImportAttempts: z.number().int().min(1).default(6),
190190
manualImportBackoffBaseMs: z.number().int().min(0).default(60_000),
191191
manualImportBackoffMaxMs: z.number().int().min(0).default(1_800_000),
192+
// Once *arr has imported a download, jack's copy in completedPath is dead weight:
193+
// *arr either hardlinked it into the library (so the data lives on through the
194+
// library's link) or copied it. Enabling this unlinks jack's directory entry —
195+
// never a recursive delete — right after a row flips to `imported`.
196+
unlinkImportedFiles: z.boolean().default(false),
192197
})
193198

194199
export type DownloadsConfig = z.infer<typeof DownloadsConfig>
195200

201+
// Raw downloads for persistence: a PATCH body merged onto the stored block, so every
202+
// field is optional and the merged result is validated through DownloadsConfig before
203+
// it reaches the file (see ConfigService.updateDownloads).
204+
export const RawDownloadsConfig = z.object({
205+
completedPath: z.string().min(1).optional(),
206+
maxConcurrentDownloads: z.number().int().min(1).optional(),
207+
maxDownloadAttempts: z.number().int().min(1).optional(),
208+
retryBaseDelayMs: z.number().int().min(0).optional(),
209+
retryMaxDelayMs: z.number().int().min(0).optional(),
210+
idleTimeoutMs: z.number().int().min(1000).optional(),
211+
importPollIntervalMs: z.number().int().min(1000).optional(),
212+
maxManualImportAttempts: z.number().int().min(1).optional(),
213+
manualImportBackoffBaseMs: z.number().int().min(0).optional(),
214+
manualImportBackoffMaxMs: z.number().int().min(0).optional(),
215+
unlinkImportedFiles: z.boolean().optional(),
216+
})
217+
218+
export type RawDownloadsConfig = z.infer<typeof RawDownloadsConfig>
219+
196220
export const AppConfig = z.object({
197221
version: z.number(),
198222
jack: JackConfig,

0 commit comments

Comments
 (0)