Skip to content

Commit 25ad757

Browse files
committed
2 parents 4e70a26 + 1f4d8d6 commit 25ad757

13 files changed

Lines changed: 320 additions & 18 deletions

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,14 @@ In order for an episode to be Monitored (processed/downloaded/updated/etc), it h
390390
| `LIBRARY_SERIES_FOLDER_NAME` | `$LIBRARY_SERIES_NAME` | Override if Media Server folder needs to be called differently from `LIBRARY_SERIES_NAME`. |
391391
| `LIBRARY_FILENAME_FORMAT` | `{SERIES_NAME} - S{ARC}E{EPISODE} - {TITLE}.mkv` | Overrides the filename each file should have, `{SERIES_NAME}`, `{ARC}`, `{EPISODE}` and `{TITLE}` will be replaced with values. `.mkv` automatically added if not specified. |
392392
| `LIBRARY_CREATE_SHOW_IF_NOT_FOUND` | `true` | If `false`, the app crashes if "LIBRARY_SERIES_NAME" isn't already a Show in your Media Server (useful for catching typos on first setup). Leave `true` to auto-create the show. |
393+
| `LIBRARY_USE_HARDLINKS` | `false` | If `true`, imports create a hardlink instead of copying, so the file only takes up space once and the torrent keeps seeding, and library renames move the file instead of copying it. Falls back to copying when the download and library folders are on different filesystems, which under Docker means anything not inside the same volume mount. Defaults to `false` (copy), which preserves the previous behaviour; set to `true` to enable hardlinks. |
394+
395+
> [!IMPORTANT]
396+
> Concerning Hard Links
397+
>
398+
> In short, hard links is a way to copy a file (in this case from download folder to library folder) instantaneously and without taking double the space on your disk. There are limitations, however, the most important is that the two folders have to live on the same drive. Only turn on if you know what your doing.
399+
>
400+
> Read more about it on [trash guides](https://trash-guides.info/File-and-Folder-Structure/Hardlinks-and-Instant-Moves/).
393401
394402
---
395403

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "OnePacerr",
3-
"version": "1.7.19",
3+
"version": "1.7.20",
44
"description": "",
55
"main": "dist/index.js",
66
"type": "module",

sample.env

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ LIBRARY_SERIES_NAME=One Pace
6464
# Leave true to auto-create the show.
6565
LIBRARY_CREATE_SHOW_IF_NOT_FOUND=true
6666

67+
# If true, imports create a hardlink instead of copying and library renames move the file, saving disk space and keeping torrents seeding.
68+
# Automatically falls back to copying when the download and library folders are on different filesystems. Set false to always copy.
69+
LIBRARY_USE_HARDLINKS=false
70+
6771
####################################
6872
##### LIBRARY - NONE #####
6973
####################################

src/api/middlewares/error.middleware.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
1+
import { Logger } from 'ez-ts-logger'
12
import { HttpError } from 'routing-controllers'
23
import { beforeEach, describe, expect, it, vi } from 'vitest'
3-
import Logger from '../../util/logger.ts'
44
import { InternalServerErrorResponse } from '../interceptors/default.interceptor.ts'
55
import { HttpErrorHandler } from './error.middleware.ts'
66

77
//Gemini generated, check
88

99
// 1. Mock External Dependencies
10-
vi.mock('../../util/logger.js', () => ({
11-
default: {
10+
vi.mock('ez-ts-logger', () => ({
11+
Logger: {
1212
error: vi.fn(),
13+
warn: vi.fn(),
14+
debug: vi.fn(),
15+
info: vi.fn(),
1316
},
1417
}))
18+
const LoggerMock = Logger as any
1519

1620
vi.mock('../interceptors/default.interceptor.js', () => ({
1721
InternalServerErrorResponse: class {
@@ -91,13 +95,13 @@ describe('HttpErrorHandler', () => {
9195
expect(mockRes.on).toHaveBeenCalledWith('finish', expect.any(Function))
9296

9397
// Logger should NOT be called yet because the finish event hasn't fired
94-
expect(Logger.error).not.toHaveBeenCalled()
98+
expect(LoggerMock.error).not.toHaveBeenCalled()
9599

96100
// Manually trigger the 'finish' event callback
97101
if (finishCallback) finishCallback()
98102

99103
// Assert that logging occurs post-response finish
100-
expect(Logger.error).toHaveBeenCalledWith(
104+
expect(LoggerMock.error).toHaveBeenCalledWith(
101105
expect.any(InternalServerErrorResponse),
102106
)
103107
expect(mockNext).not.toHaveBeenCalled()

src/api/middlewares/logger.middleware.test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
1+
import { Logger } from 'ez-ts-logger'
12
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
23
import environment from '../../environment.ts'
3-
import Logger from '../../util/logger.ts'
44
import { LoggerMiddleware } from './logger.middleware.ts'
55

66
//Gemini generated, check
77

88
// 1. Mock External Dependencies
9-
vi.mock('../../util/logger.js', () => ({
10-
default: {
9+
vi.mock('ez-ts-logger', () => ({
10+
Logger: {
1111
error: vi.fn(),
1212
warn: vi.fn(),
1313
debug: vi.fn(),
1414
info: vi.fn(),
1515
},
1616
}))
17+
const LoggerMock = Logger as any
1718

1819
vi.mock('../../environment.js', () => ({
1920
default: {
@@ -86,7 +87,7 @@ describe('LoggerMiddleware', () => {
8687
// Trigger the response finish event
8788
if (finishCallback) finishCallback()
8889

89-
expect(Logger.error).toHaveBeenCalledWith(
90+
expect(LoggerMock.error).toHaveBeenCalledWith(
9091
'[503] POST {internal}/api/v1/submit (from: 127.0.0.1, resolved in 1.500s)',
9192
)
9293
expect(mockNext).toHaveBeenCalled()
@@ -105,7 +106,7 @@ describe('LoggerMiddleware', () => {
105106
vi.advanceTimersByTime(250)
106107
if (finishCallback) finishCallback()
107108

108-
expect(Logger.warn).toHaveBeenCalledWith(
109+
expect(LoggerMock.warn).toHaveBeenCalledWith(
109110
'[404] GET {internal}/api/v1/resource (from: 127.0.0.1, resolved in 0.250s)',
110111
)
111112
})
@@ -124,7 +125,7 @@ describe('LoggerMiddleware', () => {
124125
vi.advanceTimersByTime(5)
125126
if (finishCallback) finishCallback()
126127

127-
expect(Logger.debug).toHaveBeenCalledWith(
128+
expect(LoggerMock.debug).toHaveBeenCalledWith(
128129
'[200] GET {internal}/api/v1/healthz (from: 127.0.0.1, resolved in 0.005s)',
129130
)
130131
})
@@ -142,7 +143,7 @@ describe('LoggerMiddleware', () => {
142143
vi.advanceTimersByTime(1234)
143144
if (finishCallback) finishCallback()
144145

145-
expect(Logger.info).toHaveBeenCalledWith(
146+
expect(LoggerMock.info).toHaveBeenCalledWith(
146147
'[200] GET {internal}/api/v1/resource (from: 127.0.0.1, resolved in 1.234s)',
147148
)
148149
})
@@ -162,7 +163,7 @@ describe('LoggerMiddleware', () => {
162163
vi.advanceTimersByTime(0)
163164
if (finishCallback) finishCallback()
164165

165-
expect(Logger.info).toHaveBeenCalledWith(
166+
expect(LoggerMock.info).toHaveBeenCalledWith(
166167
'[200] GET {internal}/api/v1/resource (from: unknown, resolved in 0.000s)',
167168
)
168169
})

src/environment.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ export default {
9898
LIBRARY_CREATE_SHOW_IF_NOT_FOUND: /true/i.test(
9999
process.env.LIBRARY_CREATE_SHOW_IF_NOT_FOUND || 'true',
100100
),
101+
LIBRARY_USE_HARDLINKS: /true/i.test(
102+
process.env.LIBRARY_USE_HARDLINKS || 'false',
103+
),
101104

102105
/**
103106
* LIBRARY - NONE

src/pipeline/pipeline.controller.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ArcMetadata, EpisodeMetadata } from '../metadata/metadata.model.js'
88
import { QueueDownloadResult } from '../torrent/torrent.model.js'
99
import { Context } from '../util/context.js'
1010
import getFileCrc32Hash from '../util/crc32.js'
11+
import moveFile from '../util/move-file.js'
1112
import safeCopyFileSync from '../util/safe-copy-file.js'
1213
import {
1314
NoActivePipelineError,
@@ -322,11 +323,13 @@ export class PipelineController {
322323
)
323324
})
324325

325-
await safeCopyFileSync(serverFile, targetFile)
326+
if (environment.LIBRARY_USE_HARDLINKS)
327+
await moveFile(serverFile, targetFile)
328+
else await safeCopyFileSync(serverFile, targetFile)
326329

327330
await Context.library.scanLibrary(targetLibraryFile.path, arc)
328331

329-
unlinkSync(serverFile)
332+
if (!environment.LIBRARY_USE_HARDLINKS) unlinkSync(serverFile)
330333
if (trashFiles.length > 0)
331334
Logger.info(
332335
`S${arc}E${String(episode).padStart(2, '0')}${Context?.pipeline?.getReport()?.percentageString()} - Cleaning ${trashFiles.length} trash files...`,

src/torrent/torrent.controller.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '../metadata/metadata.model.js'
1313
import { Context } from '../util/context.js'
1414
import { Filter } from '../util/filters.js'
15+
import linkOrCopyFile from '../util/link-or-copy.js'
1516
import safeCopyFileSync from '../util/safe-copy-file.js'
1617
import { DelugeController } from './clients/deluge.controller.js'
1718
import { qBittorrentController } from './clients/qbittorrent.controller.js'
@@ -370,7 +371,9 @@ export class TorrentController {
370371
recursive: true,
371372
})
372373

373-
await safeCopyFileSync(source, destination)
374+
if (environment.LIBRARY_USE_HARDLINKS)
375+
await linkOrCopyFile(source, destination)
376+
else await safeCopyFileSync(source, destination)
374377

375378
Logger.info(
376379
`File for S${String(episode.arc).padStart(2, '0')}-${String(episode.episode).padStart(2, '0')} imported successfully`,

src/util/link-or-copy.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
2+
import { link } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import path from 'node:path'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6+
import linkOrCopyFile from './link-or-copy.js'
7+
8+
vi.mock('node:fs/promises', async importOriginal => {
9+
const actual = await importOriginal<typeof import('node:fs/promises')>()
10+
return { ...actual, link: vi.fn(actual.link) }
11+
})
12+
13+
describe('Link or copy', () => {
14+
let dir: string
15+
let source: string
16+
let destination: string
17+
18+
beforeEach(() => {
19+
dir = mkdtempSync(path.join(tmpdir(), 'onepacerr-link-'))
20+
source = path.join(dir, 'source.mkv')
21+
destination = path.join(dir, 'destination.mkv')
22+
writeFileSync(source, 'payload')
23+
vi.mocked(link).mockClear()
24+
})
25+
26+
afterEach(() => {
27+
rmSync(dir, { recursive: true, force: true })
28+
})
29+
30+
it('Should hardlink when source and destination share a filesystem', async () => {
31+
await linkOrCopyFile(source, destination)
32+
33+
expect(statSync(destination).nlink).toBe(2)
34+
expect(statSync(source).ino).toBe(statSync(destination).ino)
35+
})
36+
37+
it('Should replace an existing destination', async () => {
38+
writeFileSync(destination, 'stale')
39+
40+
await linkOrCopyFile(source, destination)
41+
42+
expect(readFileSync(destination, 'utf8')).toBe('payload')
43+
expect(statSync(destination).nlink).toBe(2)
44+
})
45+
46+
let COPY_FALLBACK_CODES = ['EXDEV', 'EPERM', 'ENOSYS', 'ENOTSUP', 'EMLINK']
47+
48+
it.each(COPY_FALLBACK_CODES)(
49+
'Should copy when the filesystems differ (%s)',
50+
async code => {
51+
vi.mocked(link).mockRejectedValueOnce(
52+
Object.assign(new Error('link not possible'), { code }),
53+
)
54+
55+
await linkOrCopyFile(source, destination)
56+
57+
expect(readFileSync(destination, 'utf8')).toBe('payload')
58+
expect(statSync(destination).nlink).toBe(1)
59+
},
60+
)
61+
62+
it('Should rethrow errors that are not a hardlink limitation', async () => {
63+
vi.mocked(link).mockRejectedValueOnce(
64+
Object.assign(new Error('no space left on device'), { code: 'ENOSPC' }),
65+
)
66+
67+
await expect(linkOrCopyFile(source, destination)).rejects.toThrow()
68+
})
69+
70+
it('Should leave an existing destination untouched when linking fails with a non-fallback error', async () => {
71+
writeFileSync(destination, 'original')
72+
vi.mocked(link).mockRejectedValueOnce(
73+
Object.assign(new Error('no space left on device'), { code: 'ENOSPC' }),
74+
)
75+
76+
await expect(linkOrCopyFile(source, destination)).rejects.toThrow()
77+
78+
expect(readFileSync(destination, 'utf8')).toBe('original')
79+
})
80+
81+
it('Should not corrupt the destination when two concurrent calls target it', async () => {
82+
let sourceA = source
83+
let sourceB = path.join(dir, 'source-b.mkv')
84+
writeFileSync(sourceB, 'payload-b')
85+
86+
await Promise.all([
87+
linkOrCopyFile(sourceA, destination),
88+
linkOrCopyFile(sourceB, destination),
89+
])
90+
91+
let destinationContent = readFileSync(destination, 'utf8')
92+
let destinationIno = statSync(destination).ino
93+
expect(statSync(destination).nlink).toBe(2)
94+
if (destinationContent === 'payload') {
95+
expect(destinationIno).toBe(statSync(sourceA).ino)
96+
} else {
97+
expect(destinationContent).toBe('payload-b')
98+
expect(destinationIno).toBe(statSync(sourceB).ino)
99+
}
100+
101+
let leftoverTempFiles = readdirSync(dir).filter(name =>
102+
name.includes('.onepacerr-tmp'),
103+
)
104+
expect(leftoverTempFiles).toEqual([])
105+
})
106+
})

src/util/link-or-copy.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Logger } from 'ez-ts-logger'
2+
import { randomUUID } from 'node:crypto'
3+
import { existsSync, unlinkSync } from 'node:fs'
4+
import { link, rename } from 'node:fs/promises'
5+
import safeCopyFileSync from './safe-copy-file.js'
6+
7+
/** errno codes meaning 'this filesystem cannot hardlink', not 'this failed' */
8+
const COPY_FALLBACK_CODES = ['EXDEV', 'EPERM', 'ENOSYS', 'ENOTSUP', 'EMLINK']
9+
10+
export default async function linkOrCopyFile(
11+
source: string,
12+
destination: string,
13+
) {
14+
let temp = `${destination}.onepacerr-tmp-${randomUUID()}`
15+
try {
16+
await link(source, temp)
17+
} catch (e) {
18+
let code = (e as NodeJS.ErrnoException)?.code || ''
19+
if (!COPY_FALLBACK_CODES.includes(code)) {
20+
Logger.error(`Error Linking '${source}' -> '${destination}'`)
21+
Logger.error(e)
22+
throw e
23+
}
24+
Logger.warn(
25+
`Hardlink not possible (${code}), falling back to copy for '${destination}'`,
26+
)
27+
await safeCopyFileSync(source, destination)
28+
return
29+
}
30+
try {
31+
await rename(temp, destination)
32+
} catch (e) {
33+
Logger.error(`Error Renaming '${temp}' -> '${destination}'`)
34+
Logger.error(e)
35+
try {
36+
if (existsSync(temp)) unlinkSync(temp)
37+
} catch (cleanupError) {
38+
Logger.error(`Error deleting '${temp}'`)
39+
Logger.error(cleanupError)
40+
}
41+
throw e
42+
}
43+
}

0 commit comments

Comments
 (0)