fix(library): rename all audio tracks of multi-file audiobooks

When renaming/organizing a book with multiple audio content files (such as
a multi-track audiobook), only the primary file was renamed to the new
destination path, leaving the other track files to collide or remain
unprocessed.

Retrieve the `sortOrder` for all files in the file-rename repository. When
renaming, detect if a book has multiple audio content tracks and resolve any
target path collisions among them by appending `-PartXX` suffixes, ordered by
their sort position.

Closes #521
This commit is contained in:
neon
2026-07-03 14:55:25 +00:00
parent 0f6656a303
commit 8a36f62b59
3 changed files with 344 additions and 22 deletions
@@ -123,6 +123,7 @@ export class FileRenameRepository {
relPath: bookFiles.relPath,
role: bookFiles.role,
format: bookFiles.format,
sortOrder: bookFiles.sortOrder,
})
.from(bookFiles)
.where(eq(bookFiles.bookId, bookId))
@@ -239,6 +239,197 @@ describe('FileRenameService', () => {
);
});
it('renames every colliding audio track with PartNN suffixes including the primary track', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode: 'book_per_folder',
fileNamingPattern: 'Clayton M. Christensen/How Will You Measure Your Life_ (2012)/{title} - {authors}',
file: {
id: 10,
absolutePath: '/library/Clayton M. Christensen/How Will You Measure Your Life_ (2012)/How.Will.You.Measure.Your.Life-Part01.mp3',
relPath: 'Clayton M. Christensen/How Will You Measure Your Life_ (2012)/How.Will.You.Measure.Your.Life-Part01.mp3',
format: 'mp3',
role: 'content',
},
bookFolderPath: '/library/Clayton M. Christensen/How Will You Measure Your Life_ (2012)',
metadata: {
title: 'How Will You Measure Your Life_',
publishedYear: 2012,
},
authors: ['Clayton M. Christensen'],
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{
id: 10,
absolutePath: '/library/Clayton M. Christensen/How Will You Measure Your Life_ (2012)/How.Will.You.Measure.Your.Life-Part01.mp3',
relPath: 'Clayton M. Christensen/How Will You Measure Your Life_ (2012)/How.Will.You.Measure.Your.Life-Part01.mp3',
role: 'content',
format: 'mp3',
sortOrder: 0,
},
{
id: 11,
absolutePath: '/library/Clayton M. Christensen/How Will You Measure Your Life_ (2012)/How.Will.You.Measure.Your.Life-Part02.mp3',
relPath: 'Clayton M. Christensen/How Will You Measure Your Life_ (2012)/How.Will.You.Measure.Your.Life-Part02.mp3',
role: 'content',
format: 'mp3',
sortOrder: 1,
},
{
id: 12,
absolutePath: '/library/Clayton M. Christensen/How Will You Measure Your Life_ (2012)/How.Will.You.Measure.Your.Life-Part03.mp3',
relPath: 'Clayton M. Christensen/How Will You Measure Your Life_ (2012)/How.Will.You.Measure.Your.Life-Part03.mp3',
role: 'content',
format: 'mp3',
sortOrder: 2,
},
{
id: 13,
absolutePath: '/library/Clayton M. Christensen/How Will You Measure Your Life_ (2012)/How.Will.You.Measure.Your.Life-Cover.jpg',
relPath: 'Clayton M. Christensen/How Will You Measure Your Life_ (2012)/How.Will.You.Measure.Your.Life-Cover.jpg',
role: 'cover',
format: 'jpg',
sortOrder: null,
},
]);
const result = await service.performRename(521, 1);
const targetDir = '/library/Clayton M. Christensen/How Will You Measure Your Life_ (2012)';
expect(result).toEqual(
expect.objectContaining({
status: 'success',
oldPath: `${targetDir}/How.Will.You.Measure.Your.Life-Part01.mp3`,
newPath: `${targetDir}/How Will You Measure Your Life_ - Clayton M. Christensen-Part01.mp3`,
}),
);
expect(renameRepo.applyFolderRename).toHaveBeenCalledWith(
521,
[
{
id: 10,
absolutePath: `${targetDir}/How Will You Measure Your Life_ - Clayton M. Christensen-Part01.mp3`,
relPath:
'Clayton M. Christensen/How Will You Measure Your Life_ (2012)/How Will You Measure Your Life_ - Clayton M. Christensen-Part01.mp3',
},
{
id: 11,
absolutePath: `${targetDir}/How Will You Measure Your Life_ - Clayton M. Christensen-Part02.mp3`,
relPath:
'Clayton M. Christensen/How Will You Measure Your Life_ (2012)/How Will You Measure Your Life_ - Clayton M. Christensen-Part02.mp3',
},
{
id: 12,
absolutePath: `${targetDir}/How Will You Measure Your Life_ - Clayton M. Christensen-Part03.mp3`,
relPath:
'Clayton M. Christensen/How Will You Measure Your Life_ (2012)/How Will You Measure Your Life_ - Clayton M. Christensen-Part03.mp3',
},
{
id: 13,
absolutePath: `${targetDir}/How.Will.You.Measure.Your.Life-Cover.jpg`,
relPath: 'Clayton M. Christensen/How Will You Measure Your Life_ (2012)/How.Will.You.Measure.Your.Life-Cover.jpg',
},
],
targetDir,
);
expect(mockRename).toHaveBeenCalledTimes(3);
expect(mockRename).toHaveBeenNthCalledWith(
1,
`${targetDir}/How.Will.You.Measure.Your.Life-Part01.mp3`,
`${targetDir}/How Will You Measure Your Life_ - Clayton M. Christensen-Part01.mp3`,
);
expect(mockRename).toHaveBeenNthCalledWith(
2,
`${targetDir}/How.Will.You.Measure.Your.Life-Part02.mp3`,
`${targetDir}/How Will You Measure Your Life_ - Clayton M. Christensen-Part02.mp3`,
);
expect(mockRename).toHaveBeenNthCalledWith(
3,
`${targetDir}/How.Will.You.Measure.Your.Life-Part03.mp3`,
`${targetDir}/How Will You Measure Your Life_ - Clayton M. Christensen-Part03.mp3`,
);
});
it('uses sortOrder rather than database id when numbering colliding audio tracks', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
file: {
id: 20,
absolutePath: '/library/audio/track-01.mp3',
relPath: 'audio/track-01.mp3',
format: 'mp3',
role: 'content',
},
fileNamingPattern: '{authors}/{title}',
bookFolderPath: '/library/audio/track-01.mp3',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{ id: 10, absolutePath: '/library/audio/track-02.mp3', relPath: 'audio/track-02.mp3', role: 'content', format: 'mp3', sortOrder: 1 },
{ id: 20, absolutePath: '/library/audio/track-01.mp3', relPath: 'audio/track-01.mp3', role: 'content', format: 'mp3', sortOrder: 0 },
{ id: 30, absolutePath: '/library/audio/track-03.mp3', relPath: 'audio/track-03.mp3', role: 'content', format: 'mp3', sortOrder: 2 },
]);
const result = await service.performRename(5, 12);
expect(result).toEqual(
expect.objectContaining({
status: 'success',
newPath: '/library/Frank Herbert/Dune-Part01.mp3',
}),
);
expect(renameRepo.applyFolderRename).toHaveBeenCalledWith(
5,
[
{ id: 10, absolutePath: '/library/Frank Herbert/Dune-Part02.mp3', relPath: 'Frank Herbert/Dune-Part02.mp3' },
{ id: 20, absolutePath: '/library/Frank Herbert/Dune-Part01.mp3', relPath: 'Frank Herbert/Dune-Part01.mp3' },
{ id: 30, absolutePath: '/library/Frank Herbert/Dune-Part03.mp3', relPath: 'Frank Herbert/Dune-Part03.mp3' },
],
'/library/Frank Herbert/Dune-Part01.mp3',
);
});
it('leaves multi-track audio filenames alone when the naming pattern already makes targets unique', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
file: {
id: 10,
absolutePath: '/library/audio/track-01.mp3',
relPath: 'audio/track-01.mp3',
format: 'mp3',
role: 'content',
},
fileNamingPattern: '{authors}/{originalFilename}',
bookFolderPath: '/library/audio/track-01.mp3',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{ id: 10, absolutePath: '/library/audio/track-01.mp3', relPath: 'audio/track-01.mp3', role: 'content', format: 'mp3', sortOrder: 0 },
{ id: 11, absolutePath: '/library/audio/track-02.mp3', relPath: 'audio/track-02.mp3', role: 'content', format: 'mp3', sortOrder: 1 },
]);
const result = await service.performRename(5, 12);
expect(result).toEqual(
expect.objectContaining({
status: 'success',
newPath: '/library/Frank Herbert/track-01.mp3',
}),
);
expect(renameRepo.applyFolderRename).toHaveBeenCalledWith(
5,
[
{ id: 10, absolutePath: '/library/Frank Herbert/track-01.mp3', relPath: 'Frank Herbert/track-01.mp3' },
{ id: 11, absolutePath: '/library/Frank Herbert/track-02.mp3', relPath: 'Frank Herbert/track-02.mp3' },
],
'/library/Frank Herbert/track-01.mp3',
);
});
it('sanitizes token-derived rename destinations when cross-platform mode is enabled', async () => {
const { service, renameRepo, appSettings } = makeService();
appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(true);
@@ -496,6 +687,77 @@ describe('FileRenameService', () => {
expect(mockRename).not.toHaveBeenCalled();
});
it('skips when a generated audio part target already exists on disk', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
file: {
id: 10,
absolutePath: '/library/audio/track-01.mp3',
relPath: 'audio/track-01.mp3',
format: 'mp3',
role: 'content',
},
fileNamingPattern: '{authors}/{title}',
bookFolderPath: '/library/audio/track-01.mp3',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{ id: 10, absolutePath: '/library/audio/track-01.mp3', relPath: 'audio/track-01.mp3', role: 'content', format: 'mp3', sortOrder: 0 },
{ id: 11, absolutePath: '/library/audio/track-02.mp3', relPath: 'audio/track-02.mp3', role: 'content', format: 'mp3', sortOrder: 1 },
]);
mockAccess.mockImplementation((path: any) => {
if (path.toString() === '/library/Frank Herbert/Dune-Part02.mp3') return Promise.resolve(undefined);
return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' }));
});
const result = await service.performRename(5, 12);
expect(result).toEqual(
expect.objectContaining({
status: 'skipped',
reason: 'target path already exists on disk',
newPath: '/library/Frank Herbert/Dune-Part01.mp3',
}),
);
expect(renameRepo.applyFolderRename).not.toHaveBeenCalled();
expect(mockRename).not.toHaveBeenCalled();
});
it('skips when a generated audio part target is taken by another book', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
file: {
id: 10,
absolutePath: '/library/audio/track-01.mp3',
relPath: 'audio/track-01.mp3',
format: 'mp3',
role: 'content',
},
fileNamingPattern: '{authors}/{title}',
bookFolderPath: '/library/audio/track-01.mp3',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{ id: 10, absolutePath: '/library/audio/track-01.mp3', relPath: 'audio/track-01.mp3', role: 'content', format: 'mp3', sortOrder: 0 },
{ id: 11, absolutePath: '/library/audio/track-02.mp3', relPath: 'audio/track-02.mp3', role: 'content', format: 'mp3', sortOrder: 1 },
]);
renameRepo.checkPathTakenByOtherBook.mockImplementation((path: string) => Promise.resolve(path === '/library/Frank Herbert/Dune-Part02.mp3'));
const result = await service.performRename(5, 12);
expect(result).toEqual(
expect.objectContaining({
status: 'skipped',
reason: 'collision',
newPath: '/library/Frank Herbert/Dune-Part01.mp3',
}),
);
expect(renameRepo.applyFolderRename).not.toHaveBeenCalled();
expect(mockRename).not.toHaveBeenCalled();
});
it('renames only the file (not folder) in book_per_folder mode when folder path is unchanged', async () => {
const { service, renameRepo } = makeService();
// Pattern resolves to the same folder but a different filename
@@ -4,7 +4,7 @@ import { access, mkdir, readdir, rename as fsRename, rmdir } from 'fs/promises';
import { basename, dirname, extname, join, relative } from 'path';
import type { FileRenameResult } from '@bookorbit/types';
import { NotificationType, resolveUploadPath } from '@bookorbit/types';
import { isAudioFormat, NotificationType, resolveUploadPath } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { NotificationService } from '../notification/notification.service';
@@ -19,6 +19,8 @@ const DEFAULT_RENAME_DEBOUNCE_MS = 3_000;
export const RENAME_RELEVANT_FIELDS = new Set(['title', 'authors', 'seriesName', 'seriesIndex', 'publishedYear'] as const);
type RenameBookFile = Awaited<ReturnType<FileRenameRepository['findAllBookFiles']>>[number];
@Injectable()
export class FileRenameService implements OnModuleDestroy {
private readonly logger = new Logger(FileRenameService.name);
@@ -114,22 +116,18 @@ export class FileRenameService implements OnModuleDestroy {
}
const currentAbsolutePath = data.file.absolutePath;
const newAbsolutePath = join(data.libraryFolderPath, resolvedRelPath);
const baseNewAbsolutePath = join(data.libraryFolderPath, resolvedRelPath);
const currentFolderPath = data.bookFolderPath;
const newFolderPath = dirname(newAbsolutePath);
const baseNewFolderPath = dirname(baseNewAbsolutePath);
const isBookPerFolder = data.organizationMode === 'book_per_folder';
const allFiles = await this.renameRepo.findAllBookFiles(bookId);
const fileTargets = new Map<number, string>();
const usedPaths = new Set<string>();
const bookHasOwnFolder = isBookPerFolder && currentFolderPath !== currentAbsolutePath;
let hasInternalCollision = false;
for (const file of allFiles) {
if (file.id === data.file.id) {
fileTargets.set(file.id, newAbsolutePath);
usedPaths.add(newAbsolutePath.toLowerCase());
fileTargets.set(file.id, baseNewAbsolutePath);
} else if (file.role === 'content') {
const fileExt = extname(file.absolutePath);
const fileFormat = (file.format ?? fileExt.slice(1)).toLowerCase();
@@ -143,44 +141,41 @@ export class FileRenameService implements OnModuleDestroy {
if (isBookPerFolder) {
const relToOldFolder = bookHasOwnFolder ? relative(currentFolderPath, file.absolutePath) : basename(file.absolutePath);
const oldSubDir = dirname(relToOldFolder);
targetAbs = join(newFolderPath, oldSubDir, basename(resolvedAbs));
targetAbs = join(baseNewFolderPath, oldSubDir, basename(resolvedAbs));
} else {
targetAbs = resolvedAbs;
}
} else {
const relToOldFolder = bookHasOwnFolder ? relative(currentFolderPath, file.absolutePath) : basename(file.absolutePath);
targetAbs = join(isBookPerFolder ? newFolderPath : dirname(newAbsolutePath), relToOldFolder);
targetAbs = join(isBookPerFolder ? baseNewFolderPath : dirname(baseNewAbsolutePath), relToOldFolder);
}
if (usedPaths.has(targetAbs.toLowerCase())) {
hasInternalCollision = true;
}
usedPaths.add(targetAbs.toLowerCase());
fileTargets.set(file.id, targetAbs);
} else {
const relToOldFolder = bookHasOwnFolder ? relative(currentFolderPath, file.absolutePath) : basename(file.absolutePath);
const targetAbs = join(isBookPerFolder ? newFolderPath : dirname(newAbsolutePath), relToOldFolder);
const targetAbs = join(isBookPerFolder ? baseNewFolderPath : dirname(baseNewAbsolutePath), relToOldFolder);
if (usedPaths.has(targetAbs.toLowerCase())) {
hasInternalCollision = true;
}
usedPaths.add(targetAbs.toLowerCase());
fileTargets.set(file.id, targetAbs);
}
}
if (hasInternalCollision) {
this.applyMultiTrackAudioPartSuffixes(fileTargets, allFiles, data.file.id);
if (this.hasInternalCollision(fileTargets)) {
fileTargets.clear();
fileTargets.set(data.file.id, newAbsolutePath);
fileTargets.set(data.file.id, baseNewAbsolutePath);
for (const file of allFiles) {
if (file.id !== data.file.id) {
const relToOldFolder = bookHasOwnFolder ? relative(currentFolderPath, file.absolutePath) : basename(file.absolutePath);
const targetDir = isBookPerFolder ? newFolderPath : dirname(newAbsolutePath);
const targetDir = isBookPerFolder ? baseNewFolderPath : dirname(baseNewAbsolutePath);
fileTargets.set(file.id, join(targetDir, relToOldFolder));
}
}
}
const newAbsolutePath = fileTargets.get(data.file.id) ?? baseNewAbsolutePath;
const newFolderPath = dirname(newAbsolutePath);
let pathUnchanged = newAbsolutePath === currentAbsolutePath;
if (pathUnchanged) {
for (const file of allFiles) {
@@ -424,6 +419,58 @@ export class FileRenameService implements OnModuleDestroy {
}
}
private applyMultiTrackAudioPartSuffixes(fileTargets: Map<number, string>, allFiles: RenameBookFile[], primaryFileId: number): void {
const audioFiles = allFiles.filter((file) => this.isAudioContentFile(file, primaryFileId));
if (audioFiles.length < 2) return;
const audioFilesByTarget = new Map<string, RenameBookFile[]>();
for (const file of audioFiles) {
const targetPath = fileTargets.get(file.id);
if (!targetPath) continue;
const key = targetPath.toLowerCase();
const existing = audioFilesByTarget.get(key);
if (existing) {
existing.push(file);
} else {
audioFilesByTarget.set(key, [file]);
}
}
const collidingGroups = [...audioFilesByTarget.values()].filter((group) => group.length > 1);
if (collidingGroups.length === 0) return;
const trackNumbersByFileId = new Map<number, number>();
[...audioFiles].sort(compareAudioTrackFiles).forEach((file, index) => {
trackNumbersByFileId.set(file.id, index + 1);
});
for (const group of collidingGroups) {
for (const file of group) {
const targetPath = fileTargets.get(file.id);
const trackNumber = trackNumbersByFileId.get(file.id);
if (!targetPath || !trackNumber) continue;
fileTargets.set(file.id, appendPartSuffix(targetPath, trackNumber));
}
}
}
private isAudioContentFile(file: RenameBookFile, primaryFileId: number): boolean {
const format = (file.format ?? extname(file.absolutePath).slice(1)).toLowerCase();
return Boolean(format && isAudioFormat(format) && (file.role === 'content' || file.id === primaryFileId));
}
private hasInternalCollision(fileTargets: Map<number, string>): boolean {
const seen = new Set<string>();
for (const targetPath of fileTargets.values()) {
const key = targetPath.toLowerCase();
if (seen.has(key)) return true;
seen.add(key);
}
return false;
}
private foldersAreNested(pathA: string, pathB: string): boolean {
const normA = (pathA.endsWith('/') ? pathA : pathA + '/').toLowerCase();
const normB = (pathB.endsWith('/') ? pathB : pathB + '/').toLowerCase();
@@ -518,3 +565,15 @@ function resolvePositiveInteger(value: unknown, fallback: number): number {
if (!Number.isFinite(numeric) || numeric < 1) return fallback;
return Math.floor(numeric);
}
function compareAudioTrackFiles(a: RenameBookFile, b: RenameBookFile): number {
const aSortOrder = a.sortOrder ?? Number.MAX_SAFE_INTEGER;
const bSortOrder = b.sortOrder ?? Number.MAX_SAFE_INTEGER;
return aSortOrder - bSortOrder || a.id - b.id;
}
function appendPartSuffix(targetPath: string, trackNumber: number): string {
const extension = extname(targetPath);
const stem = basename(targetPath, extension);
return join(dirname(targetPath), `${stem}-Part${String(trackNumber).padStart(2, '0')}${extension}`);
}