diff --git a/src/lib/utils/file-organizer.ts b/src/lib/utils/file-organizer.ts index 2311b53..aa0f2c0 100644 --- a/src/lib/utils/file-organizer.ts +++ b/src/lib/utils/file-organizer.ts @@ -298,9 +298,13 @@ export class FileOrganizer { // Determine if file renaming should be applied const shouldRename = renameConfig?.enabled && renameConfig.template; const isMultiFile = audioFiles.length > 1; + const duplicateBasenames = this.findDuplicateBasenames(audioFiles); + const usedTargetFilenames = new Set(); if (shouldRename) { await logger?.info(`File renaming enabled with template: ${renameConfig.template}${isMultiFile ? ` (${audioFiles.length} files, indices will be appended)` : ''}`); + } else if (duplicateBasenames.size > 0) { + await logger?.info(`Detected ${duplicateBasenames.size} duplicate source filename(s); applying folder-aware naming to avoid collisions`); } // Copy audio files (do NOT delete originals - needed for seeding) @@ -333,8 +337,13 @@ export class FileOrganizer { ext, isMultiFile ? i + 1 : undefined, ); + filename = this.makeUniqueFilename(filename, usedTargetFilenames); } else { - filename = path.basename(audioFile); + filename = this.buildSourceAwareFilename( + audioFile, + duplicateBasenames, + usedTargetFilenames + ); } const targetFilePath = path.join(targetPath, filename); @@ -628,6 +637,72 @@ export class FileOrganizer { ); } + private findDuplicateBasenames(files: string[]): Set { + const counts = new Map(); + + for (const file of files) { + const basename = path.basename(file); + counts.set(basename, (counts.get(basename) || 0) + 1); + } + + return new Set( + Array.from(counts.entries()) + .filter(([, count]) => count > 1) + .map(([basename]) => basename) + ); + } + + private buildSourceAwareFilename( + sourcePath: string, + duplicateBasenames: Set, + usedFilenames: Set + ): string { + const basename = path.basename(sourcePath); + const ext = path.extname(basename); + const stem = path.basename(basename, ext); + + let candidate = basename; + + // Preserve folder context for duplicate track names (e.g. CD1/Track01.mp3, + // CD2/Track01.mp3) so each file keeps a unique target name. + if (duplicateBasenames.has(basename) && !path.isAbsolute(sourcePath)) { + const folder = path.dirname(sourcePath); + if (folder !== '.') { + const folderPrefix = folder + .split(path.sep) + .filter(Boolean) + .map((segment) => this.sanitizePath(segment)) + .join('-'); + + if (folderPrefix) { + candidate = `${folderPrefix}-${stem}${ext}`; + } + } + } + + return this.makeUniqueFilename(candidate, usedFilenames); + } + + private makeUniqueFilename(filename: string, usedFilenames: Set): string { + if (!usedFilenames.has(filename)) { + usedFilenames.add(filename); + return filename; + } + + const ext = path.extname(filename); + const stem = path.basename(filename, ext); + let suffix = 2; + + while (true) { + const candidate = `${stem} (${suffix})${ext}`; + if (!usedFilenames.has(candidate)) { + usedFilenames.add(candidate); + return candidate; + } + suffix++; + } + } + /** * Download cover art from URL or copy from local cache */ diff --git a/tests/utils/file-organizer.test.ts b/tests/utils/file-organizer.test.ts index 93178c3..874e40b 100644 --- a/tests/utils/file-organizer.test.ts +++ b/tests/utils/file-organizer.test.ts @@ -468,6 +468,55 @@ describe('file organizer', () => { expect(result.isFile).toBe(false); }); + it('keeps nested duplicate track names unique when renaming is disabled', async () => { + configState.values.set('metadata_tagging_enabled', 'false'); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: [ + path.join('CD1', 'Track01.mp3'), + path.join('CD1', 'Track02.mp3'), + path.join('CD2', 'Track01.mp3'), + path.join('CD2', 'Track02.mp3'), + ], + coverFile: undefined, + isFile: false, + }); + + const sourceRoot = path.normalize('/downloads/book'); + fsMock.access.mockImplementation(async (filePath: string) => { + const normalized = path.normalize(filePath); + if (normalized.startsWith(sourceRoot)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + copyFileMock.copyFile.mockResolvedValue(undefined); + fsMock.chmod.mockResolvedValue(undefined); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + }, '{author}/{title}'); + + const expectedDir = path.join('/media', 'Author', 'Book'); + expect(result.success).toBe(true); + expect(result.filesMovedCount).toBe(4); + expect(result.audioFiles).toEqual([ + path.join(expectedDir, 'CD1-Track01.mp3'), + path.join(expectedDir, 'CD1-Track02.mp3'), + path.join(expectedDir, 'CD2-Track01.mp3'), + path.join(expectedDir, 'CD2-Track02.mp3'), + ]); + expect(copyFileMock.copyFile).toHaveBeenCalledWith( + path.join('/downloads', 'book', 'CD1', 'Track01.mp3'), + path.join(expectedDir, 'CD1-Track01.mp3') + ); + expect(copyFileMock.copyFile).toHaveBeenCalledWith( + path.join('/downloads', 'book', 'CD2', 'Track01.mp3'), + path.join(expectedDir, 'CD2-Track01.mp3') + ); + }); + it('returns no audio files for unsupported single files', async () => { const organizer = new FileOrganizer('/media', '/tmp'); fsMock.stat.mockResolvedValue({ isFile: () => true });