Skip to content

Commit e140fd6

Browse files
authored
Merge pull request #459 from DialmasterOrg/feat/443-feature-request-delete-directory-with-auto-cleanup-if-directory-is-empty
feat: auto-clean empty channel directories (#443)
2 parents e2e13ba + 26f16d8 commit e140fd6

File tree

8 files changed

+405
-52
lines changed

8 files changed

+405
-52
lines changed

server/modules/__tests__/videoDeletionModule.test.js

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,17 @@ describe('VideoDeletionModule', () => {
3939
promises: mockFs
4040
}));
4141

42-
// Mock the filesystem module (isVideoDirectory)
42+
// Mock the filesystem module (isVideoDirectory, cleanupEmptyChannelDirectory, cleanupEmptyParents)
4343
// Default to true (nested structure) for backwards compatibility with existing tests
4444
jest.doMock('../filesystem', () => ({
45-
isVideoDirectory: jest.fn(() => true)
45+
isVideoDirectory: jest.fn(() => true),
46+
cleanupEmptyChannelDirectory: jest.fn().mockResolvedValue(false),
47+
cleanupEmptyParents: jest.fn().mockResolvedValue()
48+
}));
49+
50+
// Mock configModule for _tryCleanupChannelDirectory
51+
jest.doMock('../configModule', () => ({
52+
directoryPath: '/test/output'
4653
}));
4754

4855
// Require the module after mocks are in place
@@ -270,6 +277,158 @@ describe('VideoDeletionModule', () => {
270277
});
271278
});
272279

280+
describe('_tryCleanupChannelDirectory', () => {
281+
let mockFilesystem;
282+
283+
beforeEach(() => {
284+
mockFilesystem = require('../filesystem');
285+
});
286+
287+
test('should call cleanupEmptyChannelDirectory with grandparent path for nested deletion', async () => {
288+
const mockVideoRecord = {
289+
id: 1,
290+
youtubeId: 'abc123',
291+
filePath: '/test/output/Channel Name/Channel Name - Video Title - abc123/video.mp4',
292+
removed: false,
293+
update: jest.fn().mockResolvedValue()
294+
};
295+
296+
mockVideo.findByPk.mockResolvedValue(mockVideoRecord);
297+
mockFs.rm.mockResolvedValue();
298+
299+
await VideoDeletionModule.deleteVideoById(1);
300+
301+
expect(mockFilesystem.cleanupEmptyChannelDirectory).toHaveBeenCalledWith(
302+
'/test/output/Channel Name',
303+
'/test/output',
304+
{ includeIgnorableFiles: true }
305+
);
306+
});
307+
308+
test('should call cleanupEmptyChannelDirectory with parent path for flat deletion', async () => {
309+
// Override isVideoDirectory to return false for flat mode
310+
mockFilesystem.isVideoDirectory.mockReturnValue(false);
311+
312+
const mockVideoRecord = {
313+
id: 1,
314+
youtubeId: 'abc123',
315+
filePath: '/test/output/Channel Name/Channel Name - Video Title [abc123].mp4',
316+
removed: false,
317+
update: jest.fn().mockResolvedValue()
318+
};
319+
320+
mockVideo.findByPk.mockResolvedValue(mockVideoRecord);
321+
mockFs.readdir.mockResolvedValue([]);
322+
323+
await VideoDeletionModule.deleteVideoById(1);
324+
325+
expect(mockFilesystem.cleanupEmptyChannelDirectory).toHaveBeenCalledWith(
326+
'/test/output/Channel Name',
327+
'/test/output',
328+
{ includeIgnorableFiles: true }
329+
);
330+
});
331+
332+
test('should NOT call cleanup when video has no filePath', async () => {
333+
const mockVideoRecord = {
334+
id: 1,
335+
youtubeId: 'abc123',
336+
filePath: null,
337+
removed: false,
338+
update: jest.fn().mockResolvedValue()
339+
};
340+
341+
mockVideo.findByPk.mockResolvedValue(mockVideoRecord);
342+
343+
await VideoDeletionModule.deleteVideoById(1);
344+
345+
expect(mockFilesystem.cleanupEmptyChannelDirectory).not.toHaveBeenCalled();
346+
});
347+
348+
test('should NOT call cleanup when file deletion fails', async () => {
349+
const mockVideoRecord = {
350+
id: 1,
351+
youtubeId: 'abc123',
352+
filePath: '/test/output/Channel/Channel - Video - abc123/video.mp4',
353+
removed: false
354+
};
355+
356+
mockVideo.findByPk.mockResolvedValue(mockVideoRecord);
357+
358+
const permissionError = new Error('EACCES');
359+
permissionError.code = 'EACCES';
360+
mockFs.rm.mockRejectedValue(permissionError);
361+
362+
await VideoDeletionModule.deleteVideoById(1);
363+
364+
expect(mockFilesystem.cleanupEmptyChannelDirectory).not.toHaveBeenCalled();
365+
});
366+
367+
test('should not affect success response when cleanup throws', async () => {
368+
mockFilesystem.cleanupEmptyChannelDirectory.mockRejectedValueOnce(new Error('cleanup failed'));
369+
370+
const mockVideoRecord = {
371+
id: 1,
372+
youtubeId: 'abc123',
373+
filePath: '/test/output/Channel Name/Channel Name - Video Title - abc123/video.mp4',
374+
removed: false,
375+
update: jest.fn().mockResolvedValue()
376+
};
377+
378+
mockVideo.findByPk.mockResolvedValue(mockVideoRecord);
379+
mockFs.rm.mockResolvedValue();
380+
381+
const result = await VideoDeletionModule.deleteVideoById(1);
382+
383+
expect(result.success).toBe(true);
384+
expect(mockLogger.warn).toHaveBeenCalledWith(
385+
expect.objectContaining({ err: expect.any(Error) }),
386+
expect.stringContaining('non-fatal')
387+
);
388+
});
389+
390+
test('should call cleanupEmptyParents when channel dir was removed', async () => {
391+
mockFilesystem.cleanupEmptyChannelDirectory.mockResolvedValueOnce(true);
392+
393+
const mockVideoRecord = {
394+
id: 1,
395+
youtubeId: 'abc123',
396+
filePath: '/test/output/__subfolder/Channel Name/Channel Name - Video Title - abc123/video.mp4',
397+
removed: false,
398+
update: jest.fn().mockResolvedValue()
399+
};
400+
401+
mockVideo.findByPk.mockResolvedValue(mockVideoRecord);
402+
mockFs.rm.mockResolvedValue();
403+
404+
await VideoDeletionModule.deleteVideoById(1);
405+
406+
expect(mockFilesystem.cleanupEmptyParents).toHaveBeenCalledWith(
407+
'/test/output/__subfolder',
408+
'/test/output'
409+
);
410+
});
411+
412+
test('should NOT call cleanupEmptyParents when channel dir was not removed', async () => {
413+
mockFilesystem.cleanupEmptyChannelDirectory.mockResolvedValueOnce(false);
414+
415+
const mockVideoRecord = {
416+
id: 1,
417+
youtubeId: 'abc123',
418+
filePath: '/test/output/Channel Name/Channel Name - Video Title - abc123/video.mp4',
419+
removed: false,
420+
update: jest.fn().mockResolvedValue()
421+
};
422+
423+
mockVideo.findByPk.mockResolvedValue(mockVideoRecord);
424+
mockFs.rm.mockResolvedValue();
425+
426+
await VideoDeletionModule.deleteVideoById(1);
427+
428+
expect(mockFilesystem.cleanupEmptyParents).not.toHaveBeenCalled();
429+
});
430+
});
431+
273432
describe('deleteVideos', () => {
274433
test('should successfully delete multiple videos', async () => {
275434
const mockVideo1 = {

server/modules/download/__tests__/downloadExecutor.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ jest.mock('../../filesystem', () => ({
8686
isMainVideoFile: jest.fn(),
8787
isVideoDirectory: jest.fn(),
8888
isChannelDirectory: jest.fn(),
89-
isDirectoryEmpty: jest.fn()
89+
isDirectoryEmpty: jest.fn(),
90+
cleanupEmptyChannelDirectory: jest.fn().mockResolvedValue(false)
9091
}));
9192

9293
const DownloadExecutor = require('../downloadExecutor');

server/modules/download/downloadExecutor.js

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -69,38 +69,9 @@ class DownloadExecutor {
6969
}
7070

7171
// Helper function to remove a channel directory if it's empty
72-
// This is called after removing a video directory to clean up empty channel folders
72+
// Delegates to the shared directoryManager implementation
7373
async cleanupEmptyChannelDirectory(channelDir) {
74-
const fsPromises = require('fs').promises;
75-
76-
try {
77-
// Verify this is actually a channel directory (not root or subfolder)
78-
if (!filesystem.isChannelDirectory(channelDir, configModule.directoryPath)) {
79-
logger.debug({ channelDir }, 'Not a channel directory, skipping cleanup');
80-
return;
81-
}
82-
83-
// Check if directory exists
84-
const exists = await fsPromises.access(channelDir).then(() => true).catch(() => false);
85-
if (!exists) {
86-
logger.debug({ channelDir }, 'Channel directory does not exist');
87-
return;
88-
}
89-
90-
// Check if directory is empty
91-
const isEmpty = await filesystem.isDirectoryEmpty(channelDir);
92-
if (!isEmpty) {
93-
logger.debug({ channelDir }, 'Channel directory not empty, keeping it');
94-
return;
95-
}
96-
97-
// Remove empty channel directory
98-
await fsPromises.rmdir(channelDir);
99-
logger.info({ channelDir }, 'Removed empty channel directory');
100-
} catch (error) {
101-
logger.error({ err: error, channelDir }, 'Error cleaning up empty channel directory');
102-
// Don't throw - this is a best-effort cleanup
103-
}
74+
await filesystem.cleanupEmptyChannelDirectory(channelDir, configModule.directoryPath);
10475
}
10576

10677
// Cleanup function for in-progress videos based on database tracking

server/modules/filesystem/__tests__/constants.test.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ const {
99
YOUTUBE_ID_DASH_PATTERN,
1010
YOUTUBE_ID_PATTERN,
1111
MAIN_VIDEO_FILE_PATTERN,
12-
FRAGMENT_FILE_PATTERN
12+
FRAGMENT_FILE_PATTERN,
13+
CHANNEL_CLEANUP_IGNORABLE_FILES
1314
} = require('../constants');
1415

1516
describe('filesystem/constants', () => {
@@ -136,4 +137,14 @@ describe('filesystem/constants', () => {
136137
expect(FRAGMENT_FILE_PATTERN.test('Channel - Title [dQw4w9WgXcQ].mp4')).toBe(false);
137138
});
138139
});
140+
141+
describe('CHANNEL_CLEANUP_IGNORABLE_FILES', () => {
142+
it('should exist and be an array', () => {
143+
expect(Array.isArray(CHANNEL_CLEANUP_IGNORABLE_FILES)).toBe(true);
144+
});
145+
146+
it('should contain poster.jpg', () => {
147+
expect(CHANNEL_CLEANUP_IGNORABLE_FILES).toContain('poster.jpg');
148+
});
149+
});
139150
});

0 commit comments

Comments
 (0)