diff --git a/client/src/features/settings/file-naming/components/PatternTokenPalette.vue b/client/src/features/settings/file-naming/components/PatternTokenPalette.vue index da7e29a6e..4bda07dde 100644 --- a/client/src/features/settings/file-naming/components/PatternTokenPalette.vue +++ b/client/src/features/settings/file-naming/components/PatternTokenPalette.vue @@ -14,6 +14,7 @@ const TOKEN_DESCRIPTION_KEYS: Record = { title: 'settings.reader.fileNaming.tokenTitle', subtitle: 'settings.reader.fileNaming.tokenSubtitle', authors: 'settings.reader.fileNaming.tokenAuthors', + narrators: 'settings.reader.fileNaming.tokenNarrators', year: 'settings.reader.fileNaming.tokenYear', series: 'settings.reader.fileNaming.tokenSeries', seriesIndex: 'settings.reader.fileNaming.tokenSeriesIndex', @@ -30,6 +31,7 @@ const MODIFIER_DESCRIPTION_KEYS: Record = { sort: 'settings.reader.fileNaming.modSort', initial: 'settings.reader.fileNaming.modInitial', fixed2: 'settings.reader.fileNaming.modFixed2', + max3: 'settings.reader.fileNaming.modMax3', upper: 'settings.reader.fileNaming.modUpper', lower: 'settings.reader.fileNaming.modLower', } diff --git a/client/src/features/settings/file-naming/lib/pattern-highlight.ts b/client/src/features/settings/file-naming/lib/pattern-highlight.ts index cbc4e8e73..9677d87fa 100644 --- a/client/src/features/settings/file-naming/lib/pattern-highlight.ts +++ b/client/src/features/settings/file-naming/lib/pattern-highlight.ts @@ -23,7 +23,7 @@ export interface PatternPiece { * its switch and returns the value unchanged, so a typo silently produces a wrong path * rather than an error. Listing them here lets both the palette and the validator agree. */ -export const PATTERN_MODIFIERS = ['first', 'sort', 'initial', 'fixed2', 'upper', 'lower'] as const +export const PATTERN_MODIFIERS = ['first', 'sort', 'initial', 'fixed2', 'max3', 'upper', 'lower'] as const export type PatternModifier = (typeof PATTERN_MODIFIERS)[number] const KNOWN_MODIFIERS = new Set(PATTERN_MODIFIERS) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index a8e530ae0..355f8ead1 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -2126,6 +2126,7 @@ "modSort": "Last, First format", "modInitial": "First letter only", "modFixed2": "Two decimal places", + "modMax3": "Up to three values, otherwise nothing", "folderOnlyPrefix": "End a pattern with", "folderOnlySuffix": "to specify a folder only. The original filename will be preserved inside it.", "conditionalLogic": "Conditional Logic", @@ -2181,6 +2182,7 @@ "tokenTitle": "Book title", "tokenSubtitle": "Book subtitle", "tokenAuthors": "Author(s), comma-separated", + "tokenNarrators": "Narrator(s), comma-separated", "tokenYear": "Publication year", "tokenSeries": "Series name", "tokenSeriesIndex": "Series index (zero-padded)", diff --git a/packages/types/src/pattern-resolver.ts b/packages/types/src/pattern-resolver.ts index 31859893c..c871f5e2e 100644 --- a/packages/types/src/pattern-resolver.ts +++ b/packages/types/src/pattern-resolver.ts @@ -9,6 +9,7 @@ export const EXAMPLE_PATTERN_METADATA: Record = { title: "Neuromancer", subtitle: "20th Anniversary Edition", authors: "William Gibson", + narrators: "Robertson Dean", year: "1984", series: "Sprawl", seriesIndex: "01", @@ -24,6 +25,7 @@ export const PATTERN_TOKENS = [ { token: "title", description: "Book title" }, { token: "subtitle", description: "Book subtitle" }, { token: "authors", description: "Author(s), comma-separated" }, + { token: "narrators", description: "Narrator(s), comma-separated" }, { token: "year", description: "Publication year" }, { token: "series", description: "Series name" }, { token: "seriesIndex", description: "Series index (zero-padded)" }, @@ -90,6 +92,13 @@ export function applyModifier(value: string, modifier: string, fieldName: string } return target.charAt(0).toUpperCase(); } + case "max3": { + const parts = value + .split(", ") + .map((part) => part.trim()) + .filter(Boolean); + return parts.length > 3 ? "" : parts.join(", "); + } case "upper": return value.toUpperCase(); case "lower": @@ -113,7 +122,11 @@ function resolveModifierPlaceholders(block: string, values: Record): boolean { const matches = [...block.matchAll(MODIFIER_PLACEHOLDER_REGEX)]; - return matches.every((m) => values[m[1]]?.trim()); + return matches.every(([, fieldName, modifier]) => { + const raw = values[fieldName] ?? ""; + if (!raw.trim()) return false; + return (modifier ? applyModifier(raw, modifier, fieldName) : raw).trim().length > 0; + }); } export function replacePlaceholders(pattern: string, values: Record): string { diff --git a/server/package.json b/server/package.json index b7cd92071..66c1e949f 100644 --- a/server/package.json +++ b/server/package.json @@ -42,7 +42,9 @@ "pretype-check": "pnpm --filter @bookorbit/types build", "type-check": "tsc --noEmit -p tsconfig.build.json", "lint": "pnpm run lint:check", + "prelint:check": "pnpm --filter @bookorbit/types build", "lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\"", + "prelint:fix": "pnpm --filter @bookorbit/types build", "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "node ../scripts/dev/with-test-lock.mjs -- vitest run --exclude test/scanner-scenarios.e2e-spec.ts --exclude test/scanner-file-operations.e2e-spec.ts", "test:watch": "vitest", diff --git a/server/src/common/utils/pattern-tokens.utils.test.ts b/server/src/common/utils/pattern-tokens.utils.test.ts new file mode 100644 index 000000000..c1020ea11 --- /dev/null +++ b/server/src/common/utils/pattern-tokens.utils.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import { buildPatternTokens } from './pattern-tokens.utils'; + +describe('pattern-tokens.utils', () => { + describe('buildPatternTokens', () => { + const metadata = { + title: 'Dune', + subtitle: 'A Novel', + publisher: 'Ace', + language: 'en', + isbn13: '9780441172719', + publishedYear: 1965, + seriesName: 'Dune Chronicles', + seriesIndex: '1', + }; + + it('builds all tokens from full metadata', () => { + const tokens = buildPatternTokens({ metadata, authors: ['Frank Herbert'], originalStem: 'dune', format: 'epub' }); + + expect(tokens).toEqual({ + originalFilename: 'dune', + extension: 'epub', + title: 'Dune', + subtitle: 'A Novel', + publisher: 'Ace', + language: 'en', + isbn: '9780441172719', + year: '1965', + series: 'Dune Chronicles', + seriesIndex: '01', + authors: 'Frank Herbert', + }); + }); + + it('joins multiple authors with comma', () => { + const tokens = buildPatternTokens({ metadata, authors: ['Author A', 'Author B'], originalStem: 'book', format: 'pdf' }); + expect(tokens['authors']).toBe('Author A, Author B'); + }); + + it('joins multiple narrators with comma', () => { + const tokens = buildPatternTokens({ metadata, narrators: ['Simon Vance', 'Scott Brick'], originalStem: 'dune', format: 'm4b' }); + expect(tokens['narrators']).toBe('Simon Vance, Scott Brick'); + }); + + it('omits narrators when none are given', () => { + expect(buildPatternTokens({ metadata, originalStem: 'dune', format: 'epub' })['narrators']).toBeUndefined(); + expect(buildPatternTokens({ metadata, narrators: [], originalStem: 'dune', format: 'epub' })['narrators']).toBeUndefined(); + }); + + it('includes the library token when a library name is given', () => { + const tokens = buildPatternTokens({ metadata, originalStem: 'dune', format: 'epub', libraryName: 'Science Fiction' }); + expect(tokens['library']).toBe('Science Fiction'); + }); + + it('omits the library token when no library name is given', () => { + expect(buildPatternTokens({ metadata, originalStem: 'dune', format: 'epub' })['library']).toBeUndefined(); + expect(buildPatternTokens({ metadata, originalStem: 'dune', format: 'epub', libraryName: '' })['library']).toBeUndefined(); + }); + + it('omits tokens for null metadata fields', () => { + const tokens = buildPatternTokens({ + metadata: { + title: null, + subtitle: null, + publisher: null, + language: null, + isbn13: null, + publishedYear: null, + seriesName: null, + seriesIndex: null, + }, + originalStem: 'file', + format: 'epub', + }); + + expect(tokens).toEqual({ originalFilename: 'file', extension: 'epub' }); + }); + + it('includes only non-null fields', () => { + const tokens = buildPatternTokens({ + metadata: { title: 'Test', publishedYear: 2023 }, + originalStem: 'test', + format: 'pdf', + }); + + expect(Object.keys(tokens).sort()).toEqual(['extension', 'originalFilename', 'title', 'year'].sort()); + }); + + it('formats decimal series index tokens without precision artifacts', () => { + const tokens = buildPatternTokens({ metadata: { ...metadata, seriesIndex: '5.02' }, originalStem: 'book', format: 'epub' }); + + expect(tokens['seriesIndex']).toBe('05.02'); + }); + }); +}); diff --git a/server/src/common/utils/pattern-tokens.utils.ts b/server/src/common/utils/pattern-tokens.utils.ts new file mode 100644 index 000000000..51a839001 --- /dev/null +++ b/server/src/common/utils/pattern-tokens.utils.ts @@ -0,0 +1,57 @@ +import { formatSeriesIndex } from './series-index-format.utils'; + +/** + * The metadata a naming pattern can draw on. Every consumer reads these same field names + * off its own row shape, so callers pass the row through rather than mapping it field by + * field. `isbn13` is the only token whose source differs between callers: the KOReader + * catalogue falls back to ISBN-10, and resolves that before calling. + */ +export interface PatternTokenMetadata { + title?: string | null; + subtitle?: string | null; + publisher?: string | null; + language?: string | null; + isbn13?: string | null; + publishedYear?: number | null; + seriesName?: string | null; + seriesIndex?: string | null; +} + +export interface PatternTokenInput { + metadata: PatternTokenMetadata; + authors?: string[]; + narrators?: string[]; + originalStem: string; + format: string; + libraryName?: string | null; +} + +/** + * Builds the token map every naming pattern resolves against. This is the single place a + * new token is added: upload, rename, bulk rename, move, Book Dock, download and the + * KOReader catalogue all resolve patterns through it, and a token wired into only some of + * them fails silently, producing a wrong path rather than an error. + * + * Absent values are left out rather than set to an empty string. `replacePlaceholders` + * treats the two identically, and omitting them keeps the map readable in logs. + */ +export function buildPatternTokens(input: PatternTokenInput): Record { + const { metadata, authors = [], narrators = [], originalStem, format, libraryName } = input; + const tokens: Record = { originalFilename: originalStem, extension: format }; + + if (libraryName) tokens['library'] = libraryName; + if (metadata.title) tokens['title'] = metadata.title; + if (metadata.subtitle) tokens['subtitle'] = metadata.subtitle; + if (metadata.publisher) tokens['publisher'] = metadata.publisher; + if (metadata.language) tokens['language'] = metadata.language; + if (metadata.isbn13) tokens['isbn'] = metadata.isbn13; + if (metadata.publishedYear) tokens['year'] = String(metadata.publishedYear); + if (metadata.seriesName) tokens['series'] = metadata.seriesName; + + const seriesIndex = formatSeriesIndex(metadata.seriesIndex ?? null); + if (seriesIndex) tokens['seriesIndex'] = seriesIndex; + if (authors.length > 0) tokens['authors'] = authors.join(', '); + if (narrators.length > 0) tokens['narrators'] = narrators.join(', '); + + return tokens; +} diff --git a/server/src/modules/book-dock/book-dock-finalize.service.ts b/server/src/modules/book-dock/book-dock-finalize.service.ts index a9a95feef..e9b16614b 100644 --- a/server/src/modules/book-dock/book-dock-finalize.service.ts +++ b/server/src/modules/book-dock/book-dock-finalize.service.ts @@ -36,7 +36,7 @@ import { SeriesMembershipService } from '../../common/services/series-membership import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; import { resolveExistingPathSpelling } from '../../common/utils/path-identity.utils'; import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils'; -import { formatSeriesIndex } from '../../common/utils/series-index-format.utils'; +import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils'; import { DB } from '../../db'; import * as schema from '../../db/schema'; import { bookMetadata, libraries, libraryFolders } from '../../db/schema'; @@ -744,7 +744,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots const pattern = libraryPattern ?? appPattern; if (pattern) { - const tokens = this.buildPatternTokens(meta, row.fileName, format, lib?.name); + const tokens = this.buildFilePatternTokens(meta, row.fileName, format, lib?.name); const resolved = lib?.organizationMode === 'book_per_file' ? resolveDownloadFilename(pattern, tokens, format, { sanitizeForCrossPlatform }) @@ -771,7 +771,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots const meta = row.selectedMetadata ?? row.embeddedMetadata ?? {}; if (pattern) { - const tokens = this.buildPatternTokens(meta, row.fileName, format, library.name); + const tokens = this.buildFilePatternTokens(meta, row.fileName, format, library.name); const resolved = library.organizationMode === 'book_per_file' ? resolveDownloadFilename(pattern, tokens, format, { sanitizeForCrossPlatform }) @@ -785,25 +785,15 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots return join(folderPath, stem, row.fileName); } - private buildPatternTokens(meta: BookDockMetadata, fileName: string, format: string, libraryName?: string | null): Record { - const stem = basename(fileName, extname(fileName)); - const tokens: Record = { originalFilename: stem, extension: format }; - - if (libraryName) tokens['library'] = libraryName; - if (meta.title) tokens['title'] = meta.title; - if (meta.subtitle) tokens['subtitle'] = meta.subtitle; - if (meta.publisher) tokens['publisher'] = meta.publisher; - if (meta.language) tokens['language'] = meta.language; - if (meta.isbn13) tokens['isbn'] = meta.isbn13; - if (meta.publishedYear) tokens['year'] = String(meta.publishedYear); - if (meta.seriesName) tokens['series'] = meta.seriesName; - const seriesIndex = formatSeriesIndex(meta.seriesIndex ?? null); - if (seriesIndex) tokens['seriesIndex'] = seriesIndex; - if (meta.authors && meta.authors.length > 0) { - tokens['authors'] = meta.authors.join(', '); - } - - return tokens; + private buildFilePatternTokens(meta: BookDockMetadata, fileName: string, format: string, libraryName?: string | null): Record { + return buildPatternTokens({ + metadata: meta, + authors: meta.authors, + narrators: meta.narrators, + originalStem: basename(fileName, extname(fileName)), + format, + libraryName, + }); } private async applyMetadata(bookId: number, row: BookDockFileRow): Promise { diff --git a/server/src/modules/book-move/book-move-planner.service.ts b/server/src/modules/book-move/book-move-planner.service.ts index 218d47698..53fbf887f 100644 --- a/server/src/modules/book-move/book-move-planner.service.ts +++ b/server/src/modules/book-move/book-move-planner.service.ts @@ -4,7 +4,7 @@ import { basename, dirname, extname, join, relative } from 'path'; import type { BookMoveCollisionKind, BookMoveCollisionPolicy, BookMoveIneligibleReason, BookMoveLayoutChange } from '@bookorbit/types'; import { resolveUploadPath } from '@bookorbit/types'; -import { buildTokens } from '../file-write/file-rename.utils'; +import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils'; import type { MoveBookData, MoveBookFile, MoveTargetLibrary } from './book-move.repository'; import { MAX_PATH_LENGTH, isInsideRoot, withCollisionSuffix } from './book-move.utils'; @@ -213,7 +213,14 @@ export class BookMovePlannerService { private resolvePathForFile(file: MoveBookFile, book: MoveBookData, input: PlanInput, format: string): string | null { const originalStem = basename(file.absolutePath, extname(file.absolutePath)); - const tokens = buildTokens(book.metadata, book.authors, originalStem, format, input.target.libraryName); + const tokens = buildPatternTokens({ + metadata: book.metadata, + authors: book.authors, + narrators: book.narrators, + originalStem, + format, + libraryName: input.target.libraryName, + }); return resolveUploadPath(input.pattern!, tokens, format, { sanitizeForCrossPlatform: input.sanitizeForCrossPlatform }); } diff --git a/server/src/modules/book-move/book-move.repository.ts b/server/src/modules/book-move/book-move.repository.ts index af7b8affc..d96d27cfa 100644 --- a/server/src/modules/book-move/book-move.repository.ts +++ b/server/src/modules/book-move/book-move.repository.ts @@ -10,10 +10,12 @@ import { bookFiles, bookMetadata, bookMoveJobs, + bookNarrators, books, koboDevices, libraries, libraryFolders, + narrators, userLibraryAccess, users, } from '../../db/schema'; @@ -53,6 +55,7 @@ export interface MoveBookData { seriesIndex: string | null; }; authors: string[]; + narrators: string[]; files: MoveBookFile[]; } @@ -163,12 +166,20 @@ export class BookMoveRepository { .where(inArray(bookFiles.bookId, batchIds)) .orderBy(asc(bookFiles.id)); - const authorRows = await this.db - .select({ bookId: bookAuthors.bookId, name: authors.name }) - .from(bookAuthors) - .innerJoin(authors, eq(authors.id, bookAuthors.authorId)) - .where(inArray(bookAuthors.bookId, batchIds)) - .orderBy(asc(bookAuthors.displayOrder)); + const [authorRows, narratorRows] = await Promise.all([ + this.db + .select({ bookId: bookAuthors.bookId, name: authors.name }) + .from(bookAuthors) + .innerJoin(authors, eq(authors.id, bookAuthors.authorId)) + .where(inArray(bookAuthors.bookId, batchIds)) + .orderBy(asc(bookAuthors.displayOrder)), + this.db + .select({ bookId: bookNarrators.bookId, name: narrators.name }) + .from(bookNarrators) + .innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId)) + .where(inArray(bookNarrators.bookId, batchIds)) + .orderBy(asc(bookNarrators.displayOrder)), + ]); const filesByBook = new Map(); for (const file of fileRows) { @@ -193,6 +204,13 @@ export class BookMoveRepository { else authorsByBook.set(author.bookId, [author.name]); } + const narratorsByBook = new Map(); + for (const narrator of narratorRows) { + const list = narratorsByBook.get(narrator.bookId); + if (list) list.push(narrator.name); + else narratorsByBook.set(narrator.bookId, [narrator.name]); + } + for (const row of bookRows) { rows.push({ bookId: row.bookId, @@ -215,6 +233,7 @@ export class BookMoveRepository { seriesIndex: row.seriesIndex, }, authors: authorsByBook.get(row.bookId) ?? [], + narrators: narratorsByBook.get(row.bookId) ?? [], files: filesByBook.get(row.bookId) ?? [], }); } diff --git a/server/src/modules/book/book.repository.test.ts b/server/src/modules/book/book.repository.test.ts index 584278755..36ba3782a 100644 --- a/server/src/modules/book/book.repository.test.ts +++ b/server/src/modules/book/book.repository.test.ts @@ -666,7 +666,7 @@ describe('BookRepository', () => { expect(db.select).not.toHaveBeenCalled(); }); - it('merges metadata rows with ordered author names per book', async () => { + it('merges metadata rows with ordered author and narrator names per book', async () => { const metaRows = [ { bookId: 10, @@ -696,11 +696,16 @@ describe('BookRepository', () => { { bookId: 10, name: 'Coauthor' }, { bookId: 11, name: 'Dan Simmons' }, ]; + const narratorRows = [ + { bookId: 10, name: 'Simon Vance' }, + { bookId: 10, name: 'Scott Brick' }, + ]; const metaChain = makeSelectChain('where', metaRows); const authorChain = makeSelectChain('orderBy', authorRows); + const narratorChain = makeSelectChain('orderBy', narratorRows); const db = { - select: vi.fn().mockReturnValueOnce(metaChain).mockReturnValueOnce(authorChain), + select: vi.fn().mockReturnValueOnce(metaChain).mockReturnValueOnce(authorChain).mockReturnValueOnce(narratorChain), }; const repo = new BookRepository(db as never); @@ -711,10 +716,12 @@ describe('BookRepository', () => { { ...metaRows[0], authors: ['Frank Herbert', 'Coauthor'], + narrators: ['Simon Vance', 'Scott Brick'], }, { ...metaRows[1], authors: ['Dan Simmons'], + narrators: [], }, ]); }); diff --git a/server/src/modules/book/book.repository.ts b/server/src/modules/book/book.repository.ts index bf5790e2d..df23164d7 100644 --- a/server/src/modules/book/book.repository.ts +++ b/server/src/modules/book/book.repository.ts @@ -113,6 +113,7 @@ type PatternMetadataRow = { seriesIndex: string | null; isbn13: string | null; authors: string[]; + narrators: string[]; }; function parseDateByBookId(value: JsonObj | null | undefined): Record { @@ -1759,7 +1760,7 @@ export class BookRepository { async findPatternMetadataByBookIds(bookIds: number[]): Promise { if (bookIds.length === 0) return []; - const [metaRows, authorRows] = await Promise.all([ + const [metaRows, authorRows, narratorRows] = await Promise.all([ this.db .select({ bookId: books.id, @@ -1785,16 +1786,32 @@ export class BookRepository { .innerJoin(authors, eq(authors.id, bookAuthors.authorId)) .where(inArray(bookAuthors.bookId, bookIds)) .orderBy(bookAuthors.displayOrder), + this.db + .select({ bookId: bookNarrators.bookId, name: narrators.name }) + .from(bookNarrators) + .innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId)) + .where(inArray(bookNarrators.bookId, bookIds)) + .orderBy(bookNarrators.displayOrder), ]); - const authorsByBookId = new Map(); - for (const row of authorRows) { - const list = authorsByBookId.get(row.bookId) ?? []; - list.push(row.name); - authorsByBookId.set(row.bookId, list); - } + const groupByBookId = (rows: { bookId: number; name: string }[]): Map => { + const byBookId = new Map(); + for (const row of rows) { + const list = byBookId.get(row.bookId) ?? []; + list.push(row.name); + byBookId.set(row.bookId, list); + } + return byBookId; + }; - return metaRows.map((row) => ({ ...row, authors: authorsByBookId.get(row.bookId) ?? [] })); + const authorsByBookId = groupByBookId(authorRows); + const narratorsByBookId = groupByBookId(narratorRows); + + return metaRows.map((row) => ({ + ...row, + authors: authorsByBookId.get(row.bookId) ?? [], + narrators: narratorsByBookId.get(row.bookId) ?? [], + })); } async findAllIds(): Promise { diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index b5af656bb..5814d83ff 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -20,7 +20,7 @@ import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; import { normalizeMetadataText, normalizeMetadataTextKey } from '../../common/utils/metadata-text-normalize.utils'; import { naturalCompare } from '../../common/utils/natural-sort.utils'; import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils'; -import { formatSeriesIndex } from '../../common/utils/series-index-format.utils'; +import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils'; import { SeriesExpectedCountService } from '../../common/services/series-expected-count.service'; import { SeriesMembershipService } from '../../common/services/series-membership.service'; import { isDateKey, resolveTimeZone, toDateKeyInTimeZone, toTimeZoneStartOfDay } from '../../common/utils/timezone.utils'; @@ -1371,23 +1371,15 @@ export class BookService { const pathExtension = extname(absolutePath).toLowerCase().slice(1); const extension = pathExtension || (format && format !== 'unknown' ? format : 'bin'); const stem = basename(absolutePath, extname(absolutePath)); - const tokens: Record = { originalFilename: stem, extension }; - if (!meta) return tokens; - if (meta.libraryName) tokens['library'] = meta.libraryName; - if (meta.title) tokens['title'] = meta.title; - if (meta.subtitle) tokens['subtitle'] = meta.subtitle; - if (meta.publisher) tokens['publisher'] = meta.publisher; - if (meta.language) tokens['language'] = meta.language; - if (meta.isbn13) tokens['isbn'] = meta.isbn13; - if (meta.publishedYear) tokens['year'] = String(meta.publishedYear); - if (meta.seriesName) tokens['series'] = meta.seriesName; - - const seriesIndex = formatSeriesIndex(meta.seriesIndex); - if (seriesIndex) tokens['seriesIndex'] = seriesIndex; - if (meta.authors.length > 0) tokens['authors'] = meta.authors.join(', '); - - return tokens; + return buildPatternTokens({ + metadata: meta ?? {}, + authors: meta?.authors, + narrators: meta?.narrators, + originalStem: stem, + format: extension, + libraryName: meta?.libraryName, + }); } private async resolveDownloadFilenameForFile(file: { bookId: number; absolutePath: string; format: string | null }): Promise { diff --git a/server/src/modules/file-write/bulk-rename.repository.test.ts b/server/src/modules/file-write/bulk-rename.repository.test.ts index a60fc828e..f876838d6 100644 --- a/server/src/modules/file-write/bulk-rename.repository.test.ts +++ b/server/src/modules/file-write/bulk-rename.repository.test.ts @@ -65,9 +65,20 @@ describe('BulkRenameRepository', () => { { bookId: 1, name: 'Author Two' }, { bookId: 2, name: 'Author Three' }, ]; + const narratorRows = [ + { bookId: 1, name: 'Narrator One' }, + { bookId: 2, name: 'Narrator Two' }, + { bookId: 2, name: 'Narrator Three' }, + ]; const db = { - select: vi.fn().mockReturnValueOnce(queryChain(bookRows)).mockReturnValueOnce(queryChain(authorRows)).mockReturnValue(subqueryChain()), + select: vi + .fn() + .mockReturnValueOnce(queryChain(bookRows)) + .mockReturnValueOnce(subqueryChain()) + .mockReturnValueOnce(queryChain(authorRows)) + .mockReturnValueOnce(queryChain(narratorRows)) + .mockReturnValue(subqueryChain()), }; const repo = new BulkRenameRepository(db as never); @@ -79,12 +90,13 @@ describe('BulkRenameRepository', () => { title: 'A', absolutePath: '/lib/old/a.epub', authors: ['Author One', 'Author Two'], + narrators: ['Narrator One'], metadata: { language: 'en', publishedYear: 2001 }, }); - expect(result[1]).toMatchObject({ bookId: 2, authors: ['Author Three'] }); + expect(result[1]).toMatchObject({ bookId: 2, authors: ['Author Three'], narrators: ['Narrator Two', 'Narrator Three'] }); }); - it('returns books with empty author lists when none are linked', async () => { + it('returns books with empty author and narrator lists when none are linked', async () => { const bookRows = [ { bookId: 1, @@ -107,7 +119,13 @@ describe('BulkRenameRepository', () => { ]; const db = { - select: vi.fn().mockReturnValueOnce(queryChain(bookRows)).mockReturnValueOnce(queryChain([])).mockReturnValue(subqueryChain()), + select: vi + .fn() + .mockReturnValueOnce(queryChain(bookRows)) + .mockReturnValueOnce(subqueryChain()) + .mockReturnValueOnce(queryChain([])) + .mockReturnValueOnce(queryChain([])) + .mockReturnValue(subqueryChain()), }; const repo = new BulkRenameRepository(db as never); @@ -115,9 +133,10 @@ describe('BulkRenameRepository', () => { expect(result).toHaveLength(1); expect(result[0].authors).toEqual([]); + expect(result[0].narrators).toEqual([]); }); - it('returns an empty array and skips the author query when the library has no books', async () => { + it('returns an empty array and skips the contributor queries when the library has no books', async () => { const db = { select: vi.fn().mockReturnValueOnce(queryChain([])) }; const repo = new BulkRenameRepository(db as never); @@ -127,7 +146,7 @@ describe('BulkRenameRepository', () => { expect(db.select).toHaveBeenCalledTimes(1); }); - it('fetches authors via a library subquery, never a per-book parameter list (issue #361)', async () => { + it('fetches contributors via a library subquery, never a per-book parameter list (issue #361)', async () => { const calls: Array<{ text: string; params: unknown[] }> = []; const fakeClient = { query: vi.fn().mockImplementation((cfg: { text: string }, params: unknown[]) => { @@ -142,16 +161,17 @@ describe('BulkRenameRepository', () => { await repo.findAllBooksForLibrary(42); - expect(calls).toHaveLength(2); + expect(calls).toHaveLength(3); - const authorQuery = calls[1]; - // The ONLY bind parameter is the libraryId - not one entry per book - so the statement can - // never exceed PostgreSQL's 65535-parameter wire-protocol limit, regardless of library size. - expect(authorQuery.params).toEqual([42]); + // The ONLY bind parameter is the libraryId - not one entry per book - so neither statement + // can exceed PostgreSQL's 65535-parameter wire-protocol limit, regardless of library size. + for (const contributorQuery of calls.slice(1)) { + expect(contributorQuery.params).toEqual([42]); - const sqlText = authorQuery.text.toLowerCase(); - expect(sqlText).toContain('in (select'); - expect(sqlText).toContain('"library_id"'); + const sqlText = contributorQuery.text.toLowerCase(); + expect(sqlText).toContain('in (select'); + expect(sqlText).toContain('"library_id"'); + } }); }); diff --git a/server/src/modules/file-write/bulk-rename.repository.ts b/server/src/modules/file-write/bulk-rename.repository.ts index 3f510955b..17898b1ea 100644 --- a/server/src/modules/file-write/bulk-rename.repository.ts +++ b/server/src/modules/file-write/bulk-rename.repository.ts @@ -4,7 +4,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { DB } from '../../db'; import * as schema from '../../db/schema'; -import { authors, bookAuthors, bookFiles, bookMetadata, books, libraries, libraryFolders } from '../../db/schema'; +import { authors, bookAuthors, bookFiles, bookMetadata, bookNarrators, books, libraries, libraryFolders, narrators } from '../../db/schema'; type Db = NodePgDatabase; @@ -30,6 +30,7 @@ export interface BulkRenameBookData { seriesIndex: string | null; }; authors: string[]; + narrators: string[]; } @Injectable() @@ -67,25 +68,44 @@ export class BulkRenameRepository { if (rows.length === 0) return []; - const authorRows = await this.db - .select({ - bookId: bookAuthors.bookId, - name: authors.name, - }) - .from(bookAuthors) - .innerJoin(authors, eq(authors.id, bookAuthors.authorId)) - .where(inArray(bookAuthors.bookId, this.db.select({ id: books.id }).from(books).where(eq(books.libraryId, libraryId)))) - .orderBy(asc(bookAuthors.bookId), asc(bookAuthors.displayOrder)); + const libraryBookIds = this.db.select({ id: books.id }).from(books).where(eq(books.libraryId, libraryId)); - const authorsByBook = new Map(); - for (const row of authorRows) { - const existing = authorsByBook.get(row.bookId); - if (existing) { - existing.push(row.name); - } else { - authorsByBook.set(row.bookId, [row.name]); + const [authorRows, narratorRows] = await Promise.all([ + this.db + .select({ + bookId: bookAuthors.bookId, + name: authors.name, + }) + .from(bookAuthors) + .innerJoin(authors, eq(authors.id, bookAuthors.authorId)) + .where(inArray(bookAuthors.bookId, libraryBookIds)) + .orderBy(asc(bookAuthors.bookId), asc(bookAuthors.displayOrder)), + this.db + .select({ + bookId: bookNarrators.bookId, + name: narrators.name, + }) + .from(bookNarrators) + .innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId)) + .where(inArray(bookNarrators.bookId, libraryBookIds)) + .orderBy(asc(bookNarrators.bookId), asc(bookNarrators.displayOrder)), + ]); + + const groupByBook = (rows: { bookId: number; name: string }[]): Map => { + const byBook = new Map(); + for (const row of rows) { + const existing = byBook.get(row.bookId); + if (existing) { + existing.push(row.name); + } else { + byBook.set(row.bookId, [row.name]); + } } - } + return byBook; + }; + + const authorsByBook = groupByBook(authorRows); + const narratorsByBook = groupByBook(narratorRows); return rows.map((row) => ({ bookId: row.bookId, @@ -109,6 +129,7 @@ export class BulkRenameRepository { seriesIndex: row.seriesIndex, }, authors: authorsByBook.get(row.bookId) ?? [], + narrators: narratorsByBook.get(row.bookId) ?? [], })); } diff --git a/server/src/modules/file-write/file-rename.repository.test.ts b/server/src/modules/file-write/file-rename.repository.test.ts index 952b6e7b1..c05974202 100644 --- a/server/src/modules/file-write/file-rename.repository.test.ts +++ b/server/src/modules/file-write/file-rename.repository.test.ts @@ -48,6 +48,12 @@ describe('FileRenameRepository', () => { innerJoin: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), orderBy: vi.fn().mockResolvedValue([{ name: 'Frank Herbert' }, { name: 'Brian Herbert' }]), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockResolvedValue([{ name: 'Simon Vance' }]), }), }; @@ -79,6 +85,7 @@ describe('FileRenameRepository', () => { seriesIndex: '1', }, authors: ['Frank Herbert', 'Brian Herbert'], + narrators: ['Simon Vance'], }); }); diff --git a/server/src/modules/file-write/file-rename.repository.ts b/server/src/modules/file-write/file-rename.repository.ts index ef44c1a44..1c18cb7f4 100644 --- a/server/src/modules/file-write/file-rename.repository.ts +++ b/server/src/modules/file-write/file-rename.repository.ts @@ -4,7 +4,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { DB } from '../../db'; import * as schema from '../../db/schema'; -import { authors, bookAuthors, bookFiles, bookMetadata, books, libraries, libraryFolders } from '../../db/schema'; +import { authors, bookAuthors, bookFiles, bookMetadata, bookNarrators, books, libraries, libraryFolders, narrators } from '../../db/schema'; type Db = NodePgDatabase; @@ -35,6 +35,7 @@ export interface BookRenameData { seriesIndex: string | null; }; authors: string[]; + narrators: string[]; } export interface BookFilePathUpdate { @@ -81,12 +82,20 @@ export class FileRenameRepository { if (!row) return null; - const authorRows = await this.db - .select({ name: authors.name }) - .from(bookAuthors) - .innerJoin(authors, eq(authors.id, bookAuthors.authorId)) - .where(eq(bookAuthors.bookId, bookId)) - .orderBy(asc(bookAuthors.displayOrder)); + const [authorRows, narratorRows] = await Promise.all([ + this.db + .select({ name: authors.name }) + .from(bookAuthors) + .innerJoin(authors, eq(authors.id, bookAuthors.authorId)) + .where(eq(bookAuthors.bookId, bookId)) + .orderBy(asc(bookAuthors.displayOrder)), + this.db + .select({ name: narrators.name }) + .from(bookNarrators) + .innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId)) + .where(eq(bookNarrators.bookId, bookId)) + .orderBy(asc(bookNarrators.displayOrder)), + ]); return { file: { @@ -115,6 +124,7 @@ export class FileRenameRepository { seriesIndex: row.seriesIndex, }, authors: authorRows.map((author) => author.name), + narrators: narratorRows.map((narrator) => narrator.name), }; } diff --git a/server/src/modules/file-write/file-rename.service.ts b/server/src/modules/file-write/file-rename.service.ts index 33d1fdae5..d5fbe7e99 100644 --- a/server/src/modules/file-write/file-rename.service.ts +++ b/server/src/modules/file-write/file-rename.service.ts @@ -13,7 +13,7 @@ import { NotificationService } from '../notification/notification.service'; import type { BookFilePathUpdate, BookRenameData } from './file-rename.repository'; import { FileRenameRepository } from './file-rename.repository'; import { FileLockService, bookOperationLockKey } from './file-lock.service'; -import { buildTokens } from './file-rename.utils'; +import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils'; const FILE_RENAME_EVENT = 'file.rename'; const FILE_RENAME_ROLLBACK_EVENT = 'file.rename_rollback'; @@ -110,7 +110,14 @@ export class FileRenameService implements OnModuleDestroy { const format = (data.file.format ?? extname(data.file.absolutePath).slice(1)).toLowerCase(); const originalStem = basename(data.file.absolutePath, extname(data.file.absolutePath)); - const tokens = buildTokens(data.metadata, data.authors, originalStem, format, data.libraryName); + const tokens = buildPatternTokens({ + metadata: data.metadata, + authors: data.authors, + narrators: data.narrators, + originalStem, + format, + libraryName: data.libraryName, + }); const sanitizeForCrossPlatform = await this.appSettings.isCrossPlatformPathSanitizationEnabled(); const resolvedRelPath = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform }); @@ -135,7 +142,14 @@ export class FileRenameService implements OnModuleDestroy { const fileExt = extname(file.absolutePath); const fileFormat = (file.format ?? fileExt.slice(1)).toLowerCase(); const fileOriginalStem = basename(file.absolutePath, fileExt); - const fileTokens = buildTokens(data.metadata, data.authors, fileOriginalStem, fileFormat, data.libraryName); + const fileTokens = buildPatternTokens({ + metadata: data.metadata, + authors: data.authors, + narrators: data.narrators, + originalStem: fileOriginalStem, + format: fileFormat, + libraryName: data.libraryName, + }); const fileResolvedRelPath = resolveUploadPath(pattern, fileTokens, fileFormat, { sanitizeForCrossPlatform }); let targetAbs: string; diff --git a/server/src/modules/file-write/file-rename.utils.test.ts b/server/src/modules/file-write/file-rename.utils.test.ts deleted file mode 100644 index 5763cd237..000000000 --- a/server/src/modules/file-write/file-rename.utils.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { buildTokens, formatSeriesIndex } from './file-rename.utils'; - -describe('file-rename.utils', () => { - describe('formatSeriesIndex', () => { - it('returns null for null input', () => { - expect(formatSeriesIndex(null)).toBeNull(); - }); - - it('formats whole number with zero-padding', () => { - expect(formatSeriesIndex('1')).toBe('01'); - expect(formatSeriesIndex('5')).toBe('05'); - expect(formatSeriesIndex('12')).toBe('12'); - }); - - it('formats fractional index correctly', () => { - expect(formatSeriesIndex('1.5')).toBe('01.5'); - expect(formatSeriesIndex('3.25')).toBe('03.25'); - expect(formatSeriesIndex('5.02')).toBe('05.02'); - expect(formatSeriesIndex('5.08')).toBe('05.08'); - expect(formatSeriesIndex('5.09')).toBe('05.09'); - expect(formatSeriesIndex('5.10')).toBe('05.10'); - expect(formatSeriesIndex('5.11')).toBe('05.11'); - }); - - it('formats zero correctly', () => { - expect(formatSeriesIndex('0')).toBe('00'); - }); - - it('formats large numbers', () => { - expect(formatSeriesIndex('100')).toBe('100'); - }); - }); - - describe('buildTokens', () => { - const fullMetadata = { - title: 'Dune', - subtitle: 'A Novel', - publisher: 'Ace', - language: 'en', - isbn13: '9780441172719', - publishedYear: 1965, - seriesName: 'Dune Chronicles', - seriesIndex: '1', - }; - - it('builds all tokens from full metadata', () => { - const tokens = buildTokens(fullMetadata, ['Frank Herbert'], 'dune', 'epub'); - - expect(tokens).toEqual({ - originalFilename: 'dune', - extension: 'epub', - title: 'Dune', - subtitle: 'A Novel', - publisher: 'Ace', - language: 'en', - isbn: '9780441172719', - year: '1965', - series: 'Dune Chronicles', - seriesIndex: '01', - authors: 'Frank Herbert', - }); - }); - - it('joins multiple authors with comma', () => { - const tokens = buildTokens(fullMetadata, ['Author A', 'Author B'], 'book', 'pdf'); - expect(tokens['authors']).toBe('Author A, Author B'); - }); - - it('includes the library token when a library name is given', () => { - const tokens = buildTokens(fullMetadata, ['Frank Herbert'], 'dune', 'epub', 'Science Fiction'); - expect(tokens['library']).toBe('Science Fiction'); - }); - - it('omits the library token when no library name is given', () => { - expect(buildTokens(fullMetadata, ['Frank Herbert'], 'dune', 'epub')['library']).toBeUndefined(); - expect(buildTokens(fullMetadata, ['Frank Herbert'], 'dune', 'epub', '')['library']).toBeUndefined(); - }); - - it('omits tokens for null metadata fields', () => { - const emptyMetadata = { - title: null, - subtitle: null, - publisher: null, - language: null, - isbn13: null, - publishedYear: null, - seriesName: null, - seriesIndex: null, - }; - - const tokens = buildTokens(emptyMetadata, [], 'file', 'epub'); - expect(tokens).toEqual({ originalFilename: 'file', extension: 'epub' }); - }); - - it('omits authors when array is empty', () => { - const tokens = buildTokens(fullMetadata, [], 'book', 'epub'); - expect(tokens['authors']).toBeUndefined(); - }); - - it('includes only non-null fields', () => { - const partialMetadata = { - title: 'Test', - subtitle: null, - publisher: null, - language: null, - isbn13: null, - publishedYear: 2023, - seriesName: null, - seriesIndex: null, - }; - - const tokens = buildTokens(partialMetadata, [], 'test', 'pdf'); - expect(Object.keys(tokens).sort()).toEqual(['extension', 'originalFilename', 'title', 'year'].sort()); - }); - - it('formats decimal series index tokens without precision artifacts', () => { - const tokens = buildTokens({ ...fullMetadata, seriesIndex: '5.02' }, [], 'book', 'epub'); - - expect(tokens['seriesIndex']).toBe('05.02'); - }); - }); -}); diff --git a/server/src/modules/file-write/file-rename.utils.ts b/server/src/modules/file-write/file-rename.utils.ts deleted file mode 100644 index b497e5168..000000000 --- a/server/src/modules/file-write/file-rename.utils.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { formatSeriesIndex } from '../../common/utils/series-index-format.utils'; - -export { formatSeriesIndex }; - -export interface RenameMetadata { - title: string | null; - subtitle: string | null; - publisher: string | null; - language: string | null; - isbn13: string | null; - publishedYear: number | null; - seriesName: string | null; - seriesIndex: string | null; -} - -export function buildTokens( - metadata: RenameMetadata, - authors: string[], - originalStem: string, - format: string, - libraryName?: string | null, -): Record { - const tokens: Record = { originalFilename: originalStem, extension: format }; - - if (libraryName) tokens['library'] = libraryName; - if (metadata.title) tokens['title'] = metadata.title; - if (metadata.subtitle) tokens['subtitle'] = metadata.subtitle; - if (metadata.publisher) tokens['publisher'] = metadata.publisher; - if (metadata.language) tokens['language'] = metadata.language; - if (metadata.isbn13) tokens['isbn'] = metadata.isbn13; - if (metadata.publishedYear) tokens['year'] = String(metadata.publishedYear); - if (metadata.seriesName) tokens['series'] = metadata.seriesName; - - const seriesIndex = formatSeriesIndex(metadata.seriesIndex); - if (seriesIndex) tokens['seriesIndex'] = seriesIndex; - if (authors.length > 0) tokens['authors'] = authors.join(', '); - - return tokens; -} diff --git a/server/src/modules/koreader/koreader-catalog.service.ts b/server/src/modules/koreader/koreader-catalog.service.ts index a2e94ef69..1a18b5422 100644 --- a/server/src/modules/koreader/koreader-catalog.service.ts +++ b/server/src/modules/koreader/koreader-catalog.service.ts @@ -8,6 +8,7 @@ import type { ConfigType } from '@nestjs/config'; import type { FastifyReply } from 'fastify'; import { DEFAULT_KOREADER_DEVICE_PATTERN, resolveUploadPath } from '@bookorbit/types'; +import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils'; import type { KoreaderCatalogBookDetail, KoreaderCatalogBookListItem, @@ -40,7 +41,6 @@ import { MAX_OFFSET_ROWS, isOffsetWithinLimit } from '../../common/constants/pag import { imageContentTypeFromPath } from '../../common/image-content-type'; import type { RequestUser } from '../../common/types/request-user'; import { contentDispositionHeader } from '../../common/utils/content-disposition.utils'; -import { formatSeriesIndex } from '../../common/utils/series-index-format.utils'; import { storageConfig } from '../../config/config'; import { BookReadService } from '../book/book-read.service'; import { BookService } from '../book/book.service'; @@ -352,7 +352,6 @@ export class KoreaderCatalogService { } private mapManifestBook(row: OpdsManifestBookRow, pattern: string, sanitizeForCrossPlatform: boolean): KoreaderCatalogManifestBook { - const authorNames = row.authors.join(', '); const files = row.files.map((file) => { const extension = this.normalizeFormat(file.format); return { @@ -365,20 +364,22 @@ export class KoreaderCatalogService { devicePath: resolveUploadPath( pattern, - { - title: row.title, - subtitle: row.subtitle ?? '', - authors: authorNames, - year: row.publishedYear ? String(row.publishedYear) : '', - series: row.seriesName ?? '', - seriesIndex: formatSeriesIndex(row.seriesIndex) ?? '', - language: row.language ?? '', - publisher: row.publisher ?? '', - isbn: row.isbn13 ?? row.isbn10 ?? '', - library: row.libraryName, - originalFilename: basename(file.filename ?? row.title, `.${extension}`), - extension, - }, + buildPatternTokens({ + metadata: { + title: row.title, + subtitle: row.subtitle, + publisher: row.publisher, + language: row.language, + isbn13: row.isbn13 ?? row.isbn10, + publishedYear: row.publishedYear, + seriesName: row.seriesName, + seriesIndex: row.seriesIndex, + }, + authors: row.authors, + originalStem: basename(file.filename ?? row.title, `.${extension}`), + format: extension, + libraryName: row.libraryName, + }), extension, { sanitizeForCrossPlatform }, ) ?? @@ -714,20 +715,22 @@ export class KoreaderCatalogService { devicePath: resolveUploadPath( filePattern, - { - title, - subtitle: detail.subtitle ?? '', - authors: detail.authors.map((author) => author.name).join(', '), - year: detail.publishedYear ? String(detail.publishedYear) : '', - series: detail.seriesName ?? '', - seriesIndex: formatSeriesIndex(detail.seriesIndex) ?? '', - language: detail.language ?? '', - publisher: detail.publisher ?? '', - isbn: detail.isbn13 ?? detail.isbn10 ?? '', - library: detail.libraryName, - originalFilename: basename(file.filename ?? title, `.${extension}`), - extension, - }, + buildPatternTokens({ + metadata: { + title, + subtitle: detail.subtitle, + publisher: detail.publisher, + language: detail.language, + isbn13: detail.isbn13 ?? detail.isbn10, + publishedYear: detail.publishedYear, + seriesName: detail.seriesName, + seriesIndex: detail.seriesIndex, + }, + authors: detail.authors.map((author) => author.name), + originalStem: basename(file.filename ?? title, `.${extension}`), + format: extension, + libraryName: detail.libraryName, + }), extension, { sanitizeForCrossPlatform }, ) ?? diff --git a/server/src/modules/library/bulk-rename.service.ts b/server/src/modules/library/bulk-rename.service.ts index cd92ce51e..11d536f03 100644 --- a/server/src/modules/library/bulk-rename.service.ts +++ b/server/src/modules/library/bulk-rename.service.ts @@ -12,7 +12,7 @@ import { FileWatcherService } from '../scanner/file-watcher.service'; import { LibraryRepository } from './library.repository'; import type { BulkRenameBookData } from '../file-write/bulk-rename.repository'; import { BulkRenameRepository } from '../file-write/bulk-rename.repository'; -import { buildTokens } from '../file-write/file-rename.utils'; +import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils'; const CACHE_TTL_MS = 60_000; @@ -262,7 +262,14 @@ export class BulkRenameService { try { const format = (book.format ?? extname(book.absolutePath).slice(1)).toLowerCase(); const originalStem = basename(book.absolutePath, extname(book.absolutePath)); - const tokens = buildTokens(book.metadata, book.authors, originalStem, format, book.libraryName); + const tokens = buildPatternTokens({ + metadata: book.metadata, + authors: book.authors, + narrators: book.narrators, + originalStem, + format, + libraryName: book.libraryName, + }); const resolvedRelPath = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform }); if (!resolvedRelPath) { diff --git a/server/src/modules/upload/pattern-resolver.test.ts b/server/src/modules/upload/pattern-resolver.test.ts index 8b28549b8..e9d3ce063 100644 --- a/server/src/modules/upload/pattern-resolver.test.ts +++ b/server/src/modules/upload/pattern-resolver.test.ts @@ -34,6 +34,11 @@ const DECIMAL_INDEX: Record = { seriesIndex: '01.5', }; +const FULL_CAST: Record = { + ...FULL, + narrators: 'Simon Vance, Scott Brick, Ilyana Kadushin, Euan Morton', +}; + describe('sanitizePathSegment', () => { it('removes a large trailing run of dots and spaces in linear time', () => { expect(sanitizePathSegment(`book${'. '.repeat(100_000)}`)).toBe('book'); @@ -105,6 +110,24 @@ describe('applyModifier', () => { }); }); + describe('max3', () => { + it('passes a single value through', () => { + expect(applyModifier('Simon Vance', 'max3', 'narrators')).toBe('Simon Vance'); + }); + + it('passes three values through', () => { + expect(applyModifier('A, B, C', 'max3', 'narrators')).toBe('A, B, C'); + }); + + it('collapses to nothing above three values', () => { + expect(applyModifier('A, B, C, D', 'max3', 'narrators')).toBe(''); + }); + + it('ignores empty entries when counting', () => { + expect(applyModifier('A, , B, , C', 'max3', 'narrators')).toBe('A, B, C'); + }); + }); + describe('unknown modifier', () => { it('returns the value unchanged', () => { expect(applyModifier('Patrick Rothfuss', 'nonexistent', 'authors')).toBe('Patrick Rothfuss'); @@ -149,6 +172,24 @@ describe('replacePlaceholders', () => { }); }); + describe('optional groups judge modifier output', () => { + it('takes the fallback when a modifier collapses to nothing', () => { + expect(replacePlaceholders('<{narrators:max3}|Full Cast>', FULL_CAST)).toBe('Full Cast'); + }); + + it('keeps the primary branch while the modifier still yields text', () => { + expect(replacePlaceholders('<{narrators:max3}|Full Cast>', FULL)).toBe('Robertson Dean'); + }); + + it('drops an unguarded group whose modifier collapses to nothing', () => { + expect(replacePlaceholders('{title}< - {narrators:max3}>', FULL_CAST)).toBe('Neuromancer'); + }); + + it('still takes the fallback when the underlying value is absent', () => { + expect(replacePlaceholders('<{narrators:max3}|Full Cast>', PARTIAL)).toBe('Full Cast'); + }); + }); + describe('modifiers', () => { it('{authors:first} picks the first author', () => { expect(replacePlaceholders('{authors:first}', MULTI_AUTHOR)).toBe('Bruce Sterling'); @@ -174,6 +215,14 @@ describe('replacePlaceholders', () => { expect(replacePlaceholders('{title:initial}', FULL)).toBe('N'); }); + it('{narrators} joins every narrator', () => { + expect(replacePlaceholders('{narrators}', FULL_CAST)).toBe('Simon Vance, Scott Brick, Ilyana Kadushin, Euan Morton'); + }); + + it('{narrators:first} picks the first narrator', () => { + expect(replacePlaceholders('{narrators:first}', FULL_CAST)).toBe('Simon Vance'); + }); + it('modifier on a missing token resolves to empty string', () => { expect(replacePlaceholders('{series:upper}', PARTIAL)).toBe(''); }); diff --git a/server/src/modules/upload/upload.service.ts b/server/src/modules/upload/upload.service.ts index 1d6d0a347..ad1f9d86f 100644 --- a/server/src/modules/upload/upload.service.ts +++ b/server/src/modules/upload/upload.service.ts @@ -6,7 +6,7 @@ import { Readable } from 'stream'; import { and, asc, eq } from 'drizzle-orm'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; -import { formatSeriesIndex } from '../../common/utils/series-index-format.utils'; +import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils'; import { DB } from '../../db'; import * as schema from '../../db/schema'; @@ -18,12 +18,13 @@ import { UploadValidatorService } from './upload-validator.service'; import { UploadStorageService } from './upload-storage.service'; import { UploadProcessorService } from './upload-processor.service'; import { FileRenameService } from '../file-write/file-rename.service'; -import { resolveDownloadFilename, resolveUploadPath } from '@bookorbit/types'; +import { isAudioFormat, resolveDownloadFilename, resolveUploadPath } from '@bookorbit/types'; import type { AddBookFileResult, UploadResult } from '@bookorbit/types'; import { extractEpubMetadata } from '../metadata/lib/epub'; import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata'; import { parseMobiFile } from '../metadata/lib/mobi-parser'; import { parsePdfFile, type PdfParseWarning } from '../metadata/lib/pdf-parser'; +import { extractAudioMetadata } from '../metadata/extractors/audio.extractor'; import { computeFileHash } from '../scanner/lib/hash'; import { resolveExistingPathSpelling } from '../../common/utils/path-identity.utils'; @@ -346,7 +347,7 @@ export class UploadService { if (pattern) { const stem = basename(filename, extname(filename)); - const tokens = await this.buildPatternTokens(tempPath, format, stem, library.name); + const tokens = await this.buildUploadPatternTokens(tempPath, format, stem, library.name); if (library.organizationMode === 'book_per_file') { const resolvedFilename = resolveDownloadFilename(pattern, tokens, format, { sanitizeForCrossPlatform }); if (resolvedFilename) { @@ -385,9 +386,13 @@ export class UploadService { return { absolutePath, bookFolderPath, relPath }; } - private async buildPatternTokens(tempPath: string, format: string, stem: string, libraryName?: string | null): Promise> { - const base: Record = { originalFilename: stem, extension: format }; - if (libraryName) base['library'] = libraryName; + private async buildUploadPatternTokens( + tempPath: string, + format: string, + stem: string, + libraryName?: string | null, + ): Promise> { + const fallback = buildPatternTokens({ metadata: {}, originalStem: stem, format, libraryName }); const event = 'upload.pattern_tokens'; const startedAt = Date.now(); @@ -402,6 +407,7 @@ export class UploadService { seriesIndex?: string | null; isbn13?: string | null; authors: { name: string }[]; + narrators?: string[]; } | null = null; if (format === 'epub') { @@ -435,22 +441,20 @@ export class UploadService { if (pdf) { parsed = { title: pdf.title, publisher: pdf.publisher, authors: pdf.authors, seriesName: null, seriesIndex: null }; } + } else if (isAudioFormat(format)) { + parsed = await extractAudioMetadata(tempPath); } - if (!parsed) return base; + if (!parsed) return fallback; - if (parsed.title) base['title'] = parsed.title; - if (parsed.subtitle) base['subtitle'] = parsed.subtitle; - if (parsed.publisher) base['publisher'] = parsed.publisher; - if (parsed.language) base['language'] = parsed.language; - if (parsed.isbn13) base['isbn'] = parsed.isbn13; - if (parsed.publishedYear) base['year'] = String(parsed.publishedYear); - if (parsed.seriesName) base['series'] = parsed.seriesName; - const seriesIndex = formatSeriesIndex(parsed.seriesIndex ?? null); - if (seriesIndex) base['seriesIndex'] = seriesIndex; - if (parsed.authors.length > 0) { - base['authors'] = parsed.authors.map((a) => a.name).join(', '); - } + return buildPatternTokens({ + metadata: parsed, + authors: parsed.authors.map((author) => author.name), + narrators: parsed.narrators, + originalStem: stem, + format, + libraryName, + }); } catch (err) { const { errorClass, errorMessage } = this.parseError(err); this.logger.warn( @@ -458,7 +462,7 @@ export class UploadService { ); } - return base; + return fallback; } private parseError(err: unknown): { errorClass: string; errorMessage: string } {