mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user