mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Fix organizer collisions for nested duplicate track names
This commit is contained in:
@@ -298,9 +298,13 @@ export class FileOrganizer {
|
|||||||
// Determine if file renaming should be applied
|
// Determine if file renaming should be applied
|
||||||
const shouldRename = renameConfig?.enabled && renameConfig.template;
|
const shouldRename = renameConfig?.enabled && renameConfig.template;
|
||||||
const isMultiFile = audioFiles.length > 1;
|
const isMultiFile = audioFiles.length > 1;
|
||||||
|
const duplicateBasenames = this.findDuplicateBasenames(audioFiles);
|
||||||
|
const usedTargetFilenames = new Set<string>();
|
||||||
|
|
||||||
if (shouldRename) {
|
if (shouldRename) {
|
||||||
await logger?.info(`File renaming enabled with template: ${renameConfig.template}${isMultiFile ? ` (${audioFiles.length} files, indices will be appended)` : ''}`);
|
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)
|
// Copy audio files (do NOT delete originals - needed for seeding)
|
||||||
@@ -333,8 +337,13 @@ export class FileOrganizer {
|
|||||||
ext,
|
ext,
|
||||||
isMultiFile ? i + 1 : undefined,
|
isMultiFile ? i + 1 : undefined,
|
||||||
);
|
);
|
||||||
|
filename = this.makeUniqueFilename(filename, usedTargetFilenames);
|
||||||
} else {
|
} else {
|
||||||
filename = path.basename(audioFile);
|
filename = this.buildSourceAwareFilename(
|
||||||
|
audioFile,
|
||||||
|
duplicateBasenames,
|
||||||
|
usedTargetFilenames
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetFilePath = path.join(targetPath, filename);
|
const targetFilePath = path.join(targetPath, filename);
|
||||||
@@ -628,6 +637,72 @@ export class FileOrganizer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private findDuplicateBasenames(files: string[]): Set<string> {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
|
||||||
|
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<string>,
|
||||||
|
usedFilenames: Set<string>
|
||||||
|
): 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>): 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
|
* Download cover art from URL or copy from local cache
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -468,6 +468,55 @@ describe('file organizer', () => {
|
|||||||
expect(result.isFile).toBe(false);
|
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 () => {
|
it('returns no audio files for unsupported single files', async () => {
|
||||||
const organizer = new FileOrganizer('/media', '/tmp');
|
const organizer = new FileOrganizer('/media', '/tmp');
|
||||||
fsMock.stat.mockResolvedValue({ isFile: () => true });
|
fsMock.stat.mockResolvedValue({ isFile: () => true });
|
||||||
|
|||||||
Reference in New Issue
Block a user