From aa311b102d95b364a71b12a3bca2262205307f72 Mon Sep 17 00:00:00 2001 From: Tobias Bunk Date: Tue, 20 Oct 2026 17:50:46 +0200 Subject: [PATCH] feat: write description, genre, media type and gapless tags (#208) Metadata tagging now also writes a fixed set of fields ffmpeg supports natively, under the existing metadata_tagging_enabled switch (no new settings): - Description (Audible summary): M4B desc + ldes, MP3 TXXX:description, FLAC DESCRIPTION. Line breaks become single spaces. - Genre: top-level genres only, joined with "; " (Audiobookshelf splits genre tags on ";"). - M4B/M4A/MP4: media type Audiobook (stik=2) and gapless (pgap=1). AudibleAudiobook gains primaryGenres (Audnexus genres of type "genre", or the top of each catalog ladder); the genres display list is unchanged. With tagging on, the organize job looks up the Audible details once per import and shares them with the missing-series backfill. A failed lookup is non-fatal and falls back to the stored description. --- documentation/integrations/audible.md | 3 +- documentation/phase3/file-organization.md | 15 ++- src/lib/integrations/audible.service.ts | 24 ++++ .../processors/organize-files.processor.ts | 11 +- src/lib/processors/organize-tag-details.ts | 57 +++++++++ src/lib/utils/file-organizer.ts | 4 + src/lib/utils/metadata-tagger.ts | 55 +++++++++ tests/integrations/audible.service.test.ts | 68 +++++++++++ .../organize-files.processor.test.ts | 95 ++++++++++++++- tests/processors/organize-tag-details.test.ts | 93 +++++++++++++++ tests/utils/file-organizer.test.ts | 7 ++ .../utils/metadata-tagger.integration.test.ts | 63 ++++++++++ tests/utils/metadata-tagger.test.ts | 109 ++++++++++++++++++ 13 files changed, 598 insertions(+), 6 deletions(-) create mode 100644 src/lib/processors/organize-tag-details.ts create mode 100644 tests/processors/organize-tag-details.test.ts diff --git a/documentation/integrations/audible.md b/documentation/integrations/audible.md index ef89ba31..8d4993f6 100644 --- a/documentation/integrations/audible.md +++ b/documentation/integrations/audible.md @@ -286,7 +286,8 @@ interface AudibleAudiobook { durationMinutes?: number; releaseDate?: string; rating?: number; - genres?: string[]; + genres?: string[]; // display: genres + sub-genre tags, max 5 + primaryGenres?: string[]; // top-level genres only (Audnexus type 'genre' / top of each catalog ladder); metadata tags series?: string; seriesPart?: string; seriesAsin?: string; diff --git a/documentation/phase3/file-organization.md b/documentation/phase3/file-organization.md index 6f04eb95..f0494eb6 100644 --- a/documentation/phase3/file-organization.md +++ b/documentation/phase3/file-organization.md @@ -42,7 +42,7 @@ Result: Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/ 3. Read media directory and path template from database config (`media_dir`, `audiobook_path_template`) 4. Apply template to create target path: `[media_dir]/[template result]/` 5. **Copy** files (not move - originals stay for seeding) -6. **Tag metadata** (if enabled) - writes correct title, author, narrator, ASIN to audio files +6. **Tag metadata** (if enabled) - writes correct title, author, narrator, ASIN, series, description, genre to audio files 7. Copy cover art if found, else download from Audible. `coverArtUrl` must be a cached thumbnail `/api/cache/thumbnails/` (plain file name, resolved in `/app/cache/thumbnails`) or an https URL on `COVER_ART_HOSTS` (`*.media-amazon.com`, `*.ssl-images-amazon.com`, `*.images-amazon.com`), up to `MAX_COVER_ART_REDIRECTS` (3) redirects with every hop checked; anything else is skipped with a warning (`src/lib/utils/cover-art-url.ts`). An existing `cover.jpg` is never replaced and not fetched for (kept, debug log; a re-import with the same cover lands here too); an empty regular-file `cover.jpg` (older interrupted write) counts as missing and is replaced (`replace` publish, size re-checked under the lock). New covers are published like audio (`src/lib/utils/cover-placement.ts` → `publishCover`): hidden partial (`copyFile` for a cached thumbnail, `writeFileAtomic` for downloaded bytes), fsync + size check, `create` publish (`EEXIST` → kept), under the per-target lock; an interrupted write leaves no `cover.jpg`. Downloaded and copied covers get the library file mode (`applyFileMode`: a refused chmod is a permission warning, a missing file a failure). At request creation, covers are stored for display if they are any http(s) URL or a safe cached path (`sanitizeCoverArtUrl`; shelf-sync Goodreads/Hardcover fallbacks stay visible); a stored cover the library can't use is replaced by a later usable one (`isLibraryCoverSource`) 8. **Coerce file formats** (if enabled) - rename .mp4 → .m4b and single-file .m4a → .m4b for Plex compatibility (see: Plex Format Coercion below) 9. **Generate file hash** - SHA256 of sorted audio filenames for library matching (see: [fixes/file-hash-matching.md](../fixes/file-hash-matching.md)) @@ -108,6 +108,18 @@ Result: Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/ - `ASIN` - Audible ASIN (custom tag) - M4B/M4A/MP4: `----:com.apple.iTunes:ASIN` - MP3: Custom ID3v2 tag +- Description - publisher summary, line breaks → single spaces + - M4B/M4A/MP4: `description` (`desc`) + `synopsis` (`ldes`) + - MP3: `description` (TXXX); FLAC: `description` (Vorbis DESCRIPTION, ffprobe shows it as `comment`) +- `genre` - top-level genres (`primaryGenres`, no sub-genre tags) joined with `; ` (Audiobookshelf splits on `;`) +- M4B/M4A/MP4 only: `media_type=2` (`stik` Audiobook), `gapless_playback=1` (`pgap`) - always written +- Fixed set, no per-field settings; all under `metadata_tagging_enabled` + +**Tag details lookup** (`src/lib/processors/organize-tag-details.ts`): +- Only when tagging is on; one `getAudiobookDetails(asin)` per import, shared with the missing-series backfill +- Description: Audible summary (Audnexus `summary`) → stored `Audiobook.description` fallback +- Lookup failure is non-fatal (logged); the book is tagged with what is stored +- Genres/description are tags only (not path template variables) **Note:** ASIN is a custom metadata tag and may not appear in standard file properties viewers (Windows/macOS/Linux). Use specialized tools to verify: ```bash @@ -142,6 +154,7 @@ exiftool "audiobook.m4b" | grep -i asin - ffmpeg (system dependency - included in Docker image) - `src/lib/utils/metadata-tagger.ts` - Tagging utility - Integrated into `src/lib/utils/file-organizer.ts` +- `src/lib/processors/organize-tag-details.ts` - description/genres for the tags **Requirements:** - ffmpeg + ffprobe must be on PATH (`/usr/local/bin`) diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts index 4ff73874..7f7e55ed 100644 --- a/src/lib/integrations/audible.service.ts +++ b/src/lib/integrations/audible.service.ts @@ -53,6 +53,8 @@ export interface AudibleAudiobook { releaseDate?: string; rating?: number; genres?: string[]; + /** Top-level genres only (no sub-genre tags), as written into the audio file tags */ + primaryGenres?: string[]; series?: string; seriesPart?: string; seriesAsin?: string; @@ -156,6 +158,14 @@ function mapCatalogProduct(product: CatalogProduct): AudibleAudiobook { ? [...new Set(genreNames)].slice(0, 5) : undefined; + // Each ladder runs from its top-level genre down to sub-genres + const primaryGenreNames = + product.category_ladders + ?.map((ladder) => ladder.ladder[0]?.name) + .filter((name): name is string => !!name) ?? []; + const primaryGenres = + primaryGenreNames.length > 0 ? [...new Set(primaryGenreNames)] : undefined; + let series: string | undefined; let seriesPart: string | undefined; let seriesAsin: string | undefined; @@ -186,6 +196,7 @@ function mapCatalogProduct(product: CatalogProduct): AudibleAudiobook { releaseDate: product.release_date ?? undefined, rating: product.rating?.overall_distribution?.display_stars ?? undefined, genres, + primaryGenres, series, seriesPart, seriesAsin, @@ -195,6 +206,18 @@ function mapCatalogProduct(product: CatalogProduct): AudibleAudiobook { }; } +/** + * Top-level genres from an Audnexus genre list. Audnexus marks the top of each + * category ladder `type: 'genre'` and the sub-genres below it `type: 'tag'`. + */ +function audnexusPrimaryGenres(genres: unknown): string[] | undefined { + if (!Array.isArray(genres)) return undefined; + const names = (genres as Array) + .map((g) => (typeof g === 'string' ? g : g?.type !== 'tag' ? g?.name : undefined)) + .filter((name): name is string => typeof name === 'string' && name.trim() !== ''); + return names.length > 0 ? [...new Set(names)] : undefined; +} + export class AudibleService { private htmlClient!: AxiosInstance; private apiClient!: AxiosInstance; @@ -686,6 +709,7 @@ export class AudibleService { releaseDate: data.releaseDate || undefined, rating: data.rating ? parseFloat(data.rating) : undefined, genres: data.genres?.map((g: any) => (typeof g === 'string' ? g : g.name)).slice(0, 5) || undefined, + primaryGenres: audnexusPrimaryGenres(data.genres), series: data.seriesPrimary?.name || undefined, seriesPart: data.seriesPrimary?.position || undefined, seriesAsin: data.seriesPrimary?.asin || undefined, diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index d1d468ef..888d0af5 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -27,6 +27,7 @@ import { } from './organize-evidence'; import { getAudibleService } from '../integrations/audible.service'; import { addAutoBlock } from '../services/blocklist.service'; +import { createAudibleDetailsLoader, resolveTagDetails } from './organize-tag-details'; /** * Process organize files job @@ -134,11 +135,13 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi let series = audiobook.series || undefined; let seriesPart = audiobook.seriesPart || undefined; + // At most one Audible details lookup per import, shared with the metadata tags below + const loadAudibleDetails = createAudibleDetailsLoader(audiobook.audibleAsin); + if (audiobook.audibleAsin && !series) { try { logger.info(`Missing series data, fetching from Audnexus for ASIN: ${audiobook.audibleAsin}`); - const audibleService = getAudibleService(); - const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin); + const audnexusData = await loadAudibleDetails(); if (audnexusData) { const updates: Record = {}; @@ -187,6 +190,9 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`); + // Description and genres for the metadata tags (looked up only when tagging is on) + const tagDetails = await resolveTagDetails(audiobook.description, loadAudibleDetails, logger); + // Get file organizer (reads media_dir from database config) const organizer = await getFileOrganizer(); @@ -223,6 +229,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi year, series, seriesPart, + ...tagDetails, }, template, jobId ? { jobId, context: 'FileOrganizer' } : undefined, diff --git a/src/lib/processors/organize-tag-details.ts b/src/lib/processors/organize-tag-details.ts new file mode 100644 index 00000000..0c7d2076 --- /dev/null +++ b/src/lib/processors/organize-tag-details.ts @@ -0,0 +1,57 @@ +/** + * Component: Organize Tag Details (Audible details for series backfill and metadata tags) + * Documentation: documentation/phase3/file-organization.md + */ + +import type { RMABLogger } from '../utils/logger'; +import { getConfigService } from '../services/config.service'; +import { getAudibleService, type AudibleAudiobook } from '../integrations/audible.service'; + +export type AudibleDetailsLoader = () => Promise; + +/** Description and genres written into the audio file tags. */ +export interface TagDetails { + description?: string; + genres?: string[]; +} + +/** + * At most one Audible details lookup per import, shared by the series backfill and + * the metadata tags. Resolves to null without an ASIN; never called until needed. + */ +export function createAudibleDetailsLoader(asin: string | null | undefined): AudibleDetailsLoader { + let pending: Promise | undefined; + return () => { + if (!asin) return Promise.resolve(null); + pending ??= getAudibleService().getAudiobookDetails(asin); + return pending; + }; +} + +/** + * Description and genres for the metadata tags. Looked up only when metadata tagging + * is on. Prefers the full Audible summary, falls back to the stored description. + * A failed lookup never blocks the import: the book is tagged with what is stored. + */ +export async function resolveTagDetails( + storedDescription: string | null | undefined, + loadDetails: AudibleDetailsLoader, + logger: RMABLogger +): Promise { + const taggingEnabled = (await getConfigService().get('metadata_tagging_enabled')) === 'true'; + if (!taggingEnabled) return {}; + + let details: AudibleAudiobook | null = null; + try { + details = await loadDetails(); + } catch (error) { + logger.warn(`Failed to fetch Audible details for metadata tags: ${error instanceof Error ? error.message : String(error)}`); + } + + const description = details?.description || storedDescription || undefined; + const genres = details?.primaryGenres?.length ? details.primaryGenres : undefined; + + logger.info(`Metadata tag details: description=${description ? `${description.length} chars` : 'none'}, genres=${genres ? genres.join(', ') : 'none'}`); + + return { description, genres }; +} diff --git a/src/lib/utils/file-organizer.ts b/src/lib/utils/file-organizer.ts index 252d2e6f..b5cf45db 100644 --- a/src/lib/utils/file-organizer.ts +++ b/src/lib/utils/file-organizer.ts @@ -38,6 +38,8 @@ export interface AudiobookMetadata { asin?: string; series?: string; seriesPart?: string; + description?: string; // Metadata tags only (not used in path templates) + genres?: string[]; // Metadata tags only (not used in path templates) } export interface OrganizationResult { @@ -299,6 +301,8 @@ export class FileOrganizer { asin: audiobook.asin, series: audiobook.series, seriesPart: audiobook.seriesPart, + description: audiobook.description, + genres: audiobook.genres, }); const successCount = taggingResults.filter((r) => r.success).length; diff --git a/src/lib/utils/metadata-tagger.ts b/src/lib/utils/metadata-tagger.ts index 10708404..17e90dae 100644 --- a/src/lib/utils/metadata-tagger.ts +++ b/src/lib/utils/metadata-tagger.ts @@ -19,8 +19,16 @@ export interface MetadataTaggingOptions { asin?: string; series?: string; seriesPart?: string; + description?: string; // Publisher summary (plain text, may contain paragraphs) + genres?: string[]; // Top-level genres, joined into one tag } +/** + * Joins genres into one tag. Audiobookshelf splits genre tags on '/', '//' or ';' + * (first match wins); Audible genre names contain '&' and ',' but never ';'. + */ +const GENRE_SEPARATOR = '; '; + export interface TaggingResult { success: boolean; filePath: string; // Original file path @@ -43,6 +51,14 @@ function sanitizeMetadataValue(value: string): string { .trim(); } +/** + * Sanitize a multi-paragraph value: line breaks become single spaces first, so + * paragraphs never run together when sanitizeMetadataValue drops the breaks. + */ +function sanitizeMultilineValue(value: string): string { + return sanitizeMetadataValue(value.replace(/[ \t]*[\r\n]+[ \t]*/g, ' ')); +} + /** * Tag audio file metadata using ffmpeg * Supports m4b/m4a/mp4, mp3, and flac files @@ -77,6 +93,11 @@ export async function tagAudioFileMetadata( const asin = metadata.asin ? sanitizeMetadataValue(metadata.asin) : undefined; const series = metadata.series ? sanitizeMetadataValue(metadata.series) : undefined; const seriesPart = metadata.seriesPart ? sanitizeMetadataValue(metadata.seriesPart) : undefined; + const description = metadata.description ? sanitizeMultilineValue(metadata.description) : undefined; + const genre = metadata.genres + ?.map(sanitizeMetadataValue) + .filter((name) => name.length > 0) + .join(GENRE_SEPARATOR); // Create temporary file path const tempFile = `${filePath}.tmp`; @@ -119,6 +140,23 @@ export async function tagAudioFileMetadata( args.push('-metadata', `episode_id=${seriesPart}`); } + if (description) { + args.push( + '-metadata', `description=${description}`, // desc atom (read by Audiobookshelf) + '-metadata', `synopsis=${description}` // ldes atom (long description) + ); + } + + if (genre) { + args.push('-metadata', `genre=${genre}`); + } + + // Mark the file as an audiobook (stik=2) with gapless playback (pgap=1) + args.push( + '-metadata', 'media_type=2', + '-metadata', 'gapless_playback=1' + ); + // Explicitly specify output format (fixes .tmp extension issue) args.push('-f', 'mp4'); } @@ -152,6 +190,14 @@ export async function tagAudioFileMetadata( args.push('-metadata', `SERIES-PART=${seriesPart}`); } + if (description) { + args.push('-metadata', `description=${description}`); + } + + if (genre) { + args.push('-metadata', `genre=${genre}`); + } + // Explicitly specify output format args.push('-f', 'flac'); } @@ -186,6 +232,15 @@ export async function tagAudioFileMetadata( args.push('-metadata', `SERIES-PART=${seriesPart}`); } + if (description) { + // TXXX frame, read back as `description` (Audiobookshelf reads this) + args.push('-metadata', `description=${description}`); + } + + if (genre) { + args.push('-metadata', `genre=${genre}`); // TCON frame + } + // Explicitly specify output format (fixes .tmp extension issue) args.push('-f', 'mp3'); } diff --git a/tests/integrations/audible.service.test.ts b/tests/integrations/audible.service.test.ts index f8d177b5..196f2994 100644 --- a/tests/integrations/audible.service.test.ts +++ b/tests/integrations/audible.service.test.ts @@ -732,6 +732,35 @@ describe('AudibleService', () => { expect(genreSet.size).toBe(5); }); + it('maps primaryGenres to the deduplicated top of each category ladder', async () => { + const products = [ + makeProduct({ + category_ladders: [ + { ladder: [{ name: 'Fiction' }, { name: 'Fantasy' }, { name: 'Epic Fantasy' }] }, + { ladder: [{ name: 'Fiction' }, { name: 'Adventure' }] }, + { ladder: [{ name: 'Young Adult' }, { name: 'Coming of Age' }] }, + { ladder: [] }, + ], + }), + ]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].primaryGenres).toEqual(['Fiction', 'Young Adult']); + }); + + it('leaves primaryGenres undefined without category ladders', async () => { + const products = [makeProduct({ category_ladders: [] })]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].primaryGenres).toBeUndefined(); + }); + it('maps language from catalog product', async () => { const products = [makeProduct({ language: 'english' })]; apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); @@ -1438,6 +1467,45 @@ describe('AudibleService', () => { expect(apiClientMock.get).not.toHaveBeenCalled(); }); + it('maps primaryGenres to the Audnexus genres of type genre, without the sub-genre tags', async () => { + axiosMock.get.mockResolvedValueOnce({ + data: { + title: 'Audnexus Book', + genres: [ + { asin: '1', name: 'Literature & Fiction', type: 'genre' }, + { asin: '2', name: 'Science Fiction & Fantasy', type: 'genre' }, + { asin: '3', name: 'Hard Science Fiction', type: 'tag' }, + { asin: '4', name: 'Space Opera', type: 'tag' }, + ], + }, + }); + + const service = new AudibleService(); + const details = await service.getAudiobookDetails('B000GENRES'); + + expect(details?.primaryGenres).toEqual(['Literature & Fiction', 'Science Fiction & Fantasy']); + // The display list keeps its existing shape (genres and tags, capped at 5) + expect(details?.genres).toEqual([ + 'Literature & Fiction', + 'Science Fiction & Fantasy', + 'Hard Science Fiction', + 'Space Opera', + ]); + }); + + it.each([ + ['plain string genres', { genres: ['Fiction', 'Fiction', 'History'] }, ['Fiction', 'History']], + ['only tags', { genres: [{ name: 'Space Opera', type: 'tag' }] }, undefined], + ['no genres', {}, undefined], + ])('maps Audnexus primaryGenres for %s', async (_label, extra, expected) => { + axiosMock.get.mockResolvedValueOnce({ data: { title: 'Audnexus Book', ...extra } }); + + const service = new AudibleService(); + const details = await service.getAudiobookDetails('B000GENRE2'); + + expect(details?.primaryGenres).toEqual(expected); + }); + it('prefers the full Audnexus summary over the short description, as plain text with paragraphs', async () => { axiosMock.get.mockResolvedValueOnce({ data: { diff --git a/tests/processors/organize-files.processor.test.ts b/tests/processors/organize-files.processor.test.ts index c32aef4a..eec79e17 100644 --- a/tests/processors/organize-files.processor.test.ts +++ b/tests/processors/organize-files.processor.test.ts @@ -783,6 +783,97 @@ describe('processOrganizeFiles', () => { }) ); }); -}); - + describe('metadata tag details', () => { + function setupBook(overrides: Record = {}) { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.findUnique.mockResolvedValue({ + id: 'a-tags', + title: 'Book', + author: 'Author', + narrator: 'Narrator', + coverArtUrl: null, + audibleAsin: 'ASIN-TAGS', + year: 2020, + description: 'Stored short blurb.', + series: null, + seriesPart: null, + ...overrides, + }); + organizerMock.organize.mockResolvedValue({ + success: true, + targetPath: '/media/Author/Book', + filesMovedCount: 1, + errors: [], + audioFiles: ['/media/Author/Book/Book.m4b'], + }); + prismaMock.audiobook.update.mockResolvedValue({}); + configMock.getBackendMode.mockResolvedValue('audiobookshelf'); + } + + function taggingEnabled(enabled: boolean) { + configMock.get.mockImplementation(async (key: string) => + key === 'metadata_tagging_enabled' ? String(enabled) : null + ); + } + + async function runOrganize() { + const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor'); + const result = await processOrganizeFiles({ + requestId: 'req-tags', + audiobookId: 'a-tags', + downloadPath: '/downloads/book', + jobId: 'job-tags', + }); + expect(result.success).toBe(true); + return organizerMock.organize.mock.calls[0][1]; + } + + it('passes the Audible summary and top-level genres, looked up once for series and tags', async () => { + setupBook(); + taggingEnabled(true); + audibleServiceMock.getAudiobookDetails.mockResolvedValue({ + asin: 'ASIN-TAGS', + title: 'Book', + author: 'Author', + description: 'Full Audible summary.', + primaryGenres: ['Science Fiction & Fantasy'], + series: 'The Saga', + seriesPart: '1', + }); + + const metadata = await runOrganize(); + + expect(audibleServiceMock.getAudiobookDetails).toHaveBeenCalledTimes(1); + expect(metadata).toMatchObject({ + series: 'The Saga', + seriesPart: '1', + description: 'Full Audible summary.', + genres: ['Science Fiction & Fantasy'], + }); + }); + + it('falls back to the stored description when the lookup finds nothing', async () => { + setupBook({ series: 'The Saga', seriesPart: '1' }); + taggingEnabled(true); + audibleServiceMock.getAudiobookDetails.mockResolvedValue(null); + + const metadata = await runOrganize(); + + expect(audibleServiceMock.getAudiobookDetails).toHaveBeenCalledWith('ASIN-TAGS'); + expect(metadata.description).toBe('Stored short blurb.'); + expect(metadata.genres).toBeUndefined(); + }); + + it('does not look anything up for tags when metadata tagging is off', async () => { + setupBook({ series: 'The Saga', seriesPart: '1' }); + taggingEnabled(false); + + const metadata = await runOrganize(); + + expect(audibleServiceMock.getAudiobookDetails).not.toHaveBeenCalled(); + expect(metadata.description).toBeUndefined(); + expect(metadata.genres).toBeUndefined(); + }); + }); +}); diff --git a/tests/processors/organize-tag-details.test.ts b/tests/processors/organize-tag-details.test.ts new file mode 100644 index 00000000..6c16d138 --- /dev/null +++ b/tests/processors/organize-tag-details.test.ts @@ -0,0 +1,93 @@ +/** + * Component: Organize Tag Details Tests + * Documentation: documentation/phase3/file-organization.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAudibleDetailsLoader, resolveTagDetails } from '@/lib/processors/organize-tag-details'; +import type { RMABLogger } from '@/lib/utils/logger'; + +const configMock = vi.hoisted(() => ({ get: vi.fn() })); +const audibleServiceMock = vi.hoisted(() => ({ getAudiobookDetails: vi.fn() })); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +vi.mock('@/lib/integrations/audible.service', () => ({ + getAudibleService: () => audibleServiceMock, +})); + +const logger = { info: vi.fn(), warn: vi.fn() } as unknown as RMABLogger; + +describe('organize tag details', () => { + beforeEach(() => { + vi.clearAllMocks(); + configMock.get.mockImplementation(async (key: string) => + key === 'metadata_tagging_enabled' ? 'true' : null + ); + }); + + describe('createAudibleDetailsLoader', () => { + it('looks the ASIN up once, however often it is asked', async () => { + const details = { asin: 'B01', title: 'Book', author: 'Author' }; + audibleServiceMock.getAudiobookDetails.mockResolvedValue(details); + const load = createAudibleDetailsLoader('B01'); + + await expect(load()).resolves.toBe(details); + await expect(load()).resolves.toBe(details); + expect(audibleServiceMock.getAudiobookDetails).toHaveBeenCalledTimes(1); + }); + + it('resolves to null without an ASIN and never calls Audible', async () => { + await expect(createAudibleDetailsLoader(null)()).resolves.toBeNull(); + expect(audibleServiceMock.getAudiobookDetails).not.toHaveBeenCalled(); + }); + + it('does not call Audible until it is asked', () => { + createAudibleDetailsLoader('B01'); + expect(audibleServiceMock.getAudiobookDetails).not.toHaveBeenCalled(); + }); + }); + + describe('resolveTagDetails', () => { + it('prefers the Audible summary and takes the top-level genres', async () => { + const load = vi.fn().mockResolvedValue({ + description: 'Full summary.', + primaryGenres: ['Romance'], + }); + + await expect(resolveTagDetails('Stored blurb.', load, logger)).resolves.toEqual({ + description: 'Full summary.', + genres: ['Romance'], + }); + }); + + it('keeps the stored description when the lookup fails, and warns', async () => { + const load = vi.fn().mockRejectedValue(new Error('Audnexus down')); + + await expect(resolveTagDetails('Stored blurb.', load, logger)).resolves.toEqual({ + description: 'Stored blurb.', + genres: undefined, + }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Audnexus down')); + }); + + it('treats an empty summary and an empty genre list as missing', async () => { + const load = vi.fn().mockResolvedValue({ description: '', primaryGenres: [] }); + + await expect(resolveTagDetails(null, load, logger)).resolves.toEqual({ + description: undefined, + genres: undefined, + }); + }); + + it.each([['false'], [null]])('returns nothing and skips the lookup when tagging is %s', async (value) => { + configMock.get.mockResolvedValue(value); + const load = vi.fn(); + + await expect(resolveTagDetails('Stored blurb.', load, logger)).resolves.toEqual({}); + expect(load).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/utils/file-organizer.test.ts b/tests/utils/file-organizer.test.ts index 1ae32c35..58a68947 100644 --- a/tests/utils/file-organizer.test.ts +++ b/tests/utils/file-organizer.test.ts @@ -316,11 +316,18 @@ describe('file organizer', () => { const result = await organizer.organize('/downloads/book', { title: 'Book', author: 'Author', + description: 'Summary.', + genres: ['Romance'], }, '{author}/{title}'); const expectedDir = path.join('/media', 'Author', 'Book'); expect(result.success).toBe(true); expect(result.targetPath).toBe(expectedDir); + // Description and genres reach the tagger (they are tags only, never path parts) + expect(metadataMock.tagMultipleFiles).toHaveBeenCalledWith( + [sourcePath], + expect.objectContaining({ description: 'Summary.', genres: ['Romance'] }) + ); expect(copyFileMock.copyFile).toHaveBeenCalledWith('/tmp/tagged.m4b', path.join(expectedDir, 'book.m4b')); expect(fsMock.unlink).toHaveBeenCalledWith('/tmp/tagged.m4b'); }); diff --git a/tests/utils/metadata-tagger.integration.test.ts b/tests/utils/metadata-tagger.integration.test.ts index 6a5800ac..d73ea723 100644 --- a/tests/utils/metadata-tagger.integration.test.ts +++ b/tests/utils/metadata-tagger.integration.test.ts @@ -132,6 +132,69 @@ describe('metadata-tagger integration (real ffmpeg)', () => { 60_000 ); + describe('description, genre and audiobook flags', () => { + const DESCRIPTION = 'A heist on Luthadel.\n\nThe second paragraph — with "quotes".'; + const FLAT_DESCRIPTION = 'A heist on Luthadel. The second paragraph — with "quotes".'; + const GENRES = ['Science Fiction & Fantasy', 'Mystery, Thriller & Suspense']; + const GENRE_TAG = 'Science Fiction & Fantasy; Mystery, Thriller & Suspense'; + + it.skipIf(skipReal)( + 'm4a: desc, ldes, genre, stik and pgap are written and read back', + async () => { + const src = await makeFixture('.m4a'); + const result = await tagAudioFileMetadata(src, { + title: 'Book', + author: 'Author', + description: DESCRIPTION, + genres: GENRES, + }); + expect(result.success).toBe(true); + const tagged = result.taggedFilePath!; + expect(await readTag(tagged, 'description')).toBe(FLAT_DESCRIPTION); + expect(await readTag(tagged, 'synopsis')).toBe(FLAT_DESCRIPTION); + expect(await readTag(tagged, 'genre')).toBe(GENRE_TAG); + expect(await readTag(tagged, 'media_type')).toBe('2'); + expect(await readTag(tagged, 'gapless_playback')).toBe('1'); + }, + 60_000 + ); + + it.skipIf(skipReal)( + 'mp3: description and genre are written and read back', + async () => { + const src = await makeFixture('.mp3'); + const result = await tagAudioFileMetadata(src, { + title: 'Book', + author: 'Author', + description: DESCRIPTION, + genres: GENRES, + }); + expect(result.success).toBe(true); + expect(await readTag(result.taggedFilePath!, 'description')).toBe(FLAT_DESCRIPTION); + expect(await readTag(result.taggedFilePath!, 'genre')).toBe(GENRE_TAG); + }, + 60_000 + ); + + it.skipIf(skipReal)( + 'flac: description and genre are written and read back', + async () => { + const src = await makeFixture('.flac'); + const result = await tagAudioFileMetadata(src, { + title: 'Book', + author: 'Author', + description: DESCRIPTION, + genres: GENRES, + }); + expect(result.success).toBe(true); + // The Vorbis DESCRIPTION field is what ffprobe reports as `comment` + expect(await readTag(result.taggedFilePath!, 'comment')).toBe(FLAT_DESCRIPTION); + expect(await readTag(result.taggedFilePath!, 'genre')).toBe(GENRE_TAG); + }, + 60_000 + ); + }); + it.skipIf(skipReal)( 'm4a: author does NOT inherit a stray leading `"` when title contains `"`', async () => { diff --git a/tests/utils/metadata-tagger.test.ts b/tests/utils/metadata-tagger.test.ts index 7b3dbc8b..d1e17270 100644 --- a/tests/utils/metadata-tagger.test.ts +++ b/tests/utils/metadata-tagger.test.ts @@ -265,6 +265,115 @@ describe('metadata tagger', () => { }); }); + describe('description, genre and audiobook flags', () => { + const DESCRIPTION = 'A heist on Luthadel.\n\nThe second paragraph.'; + const FLAT_DESCRIPTION = 'A heist on Luthadel. The second paragraph.'; + const GENRES = ['Science Fiction & Fantasy', 'Mystery, Thriller & Suspense']; + + function metadataPayloads(argv: string[]): string[] { + return argv.filter((_, i) => i > 0 && argv[i - 1] === '-metadata'); + } + + it('m4b: writes desc + ldes, joined genres, media type Audiobook and gapless', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.m4b', { + title: 'Book', + author: 'Author', + description: DESCRIPTION, + genres: GENRES, + }); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'description', FLAT_DESCRIPTION); + expectMetadataArg(argv, 'synopsis', FLAT_DESCRIPTION); + expectMetadataArg(argv, 'genre', 'Science Fiction & Fantasy; Mystery, Thriller & Suspense'); + expectMetadataArg(argv, 'media_type', '2'); + expectMetadataArg(argv, 'gapless_playback', '1'); + }); + + it('m4b: always marks the file as an audiobook, even without description or genres', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.m4a', { title: 'Book', author: 'Author' }); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'media_type', '2'); + expectMetadataArg(argv, 'gapless_playback', '1'); + const payloads = metadataPayloads(argv); + expect(payloads.some((p) => p.startsWith('description='))).toBe(false); + expect(payloads.some((p) => p.startsWith('synopsis='))).toBe(false); + expect(payloads.some((p) => p.startsWith('genre='))).toBe(false); + }); + + it.each(['/tmp/book.mp3', '/tmp/book.flac'])( + '%s: writes description and joined genres, no MP4-only fields', + async (file) => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata(file, { + title: 'Book', + author: 'Author', + description: DESCRIPTION, + genres: GENRES, + }); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'description', FLAT_DESCRIPTION); + expectMetadataArg(argv, 'genre', 'Science Fiction & Fantasy; Mystery, Thriller & Suspense'); + const payloads = metadataPayloads(argv); + expect(payloads.some((p) => p.startsWith('synopsis='))).toBe(false); + expect(payloads.some((p) => p.startsWith('media_type='))).toBe(false); + expect(payloads.some((p) => p.startsWith('gapless_playback='))).toBe(false); + } + ); + + it('turns CRLF and indented line breaks into single spaces (words never run together)', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.m4b', { + title: 'Book', + author: 'Author', + description: ' First line.\r\n\r\n Second line.\nThird\u0000 line. ', + }); + + expectMetadataArg(lastCallArgv(), 'description', 'First line. Second line. Third line.'); + }); + + it('sanitizes each genre and drops empty ones', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.m4b', { + title: 'Book', + author: 'Author', + genres: [' Romance ', '', ' ', 'History'], + }); + + expectMetadataArg(lastCallArgv(), 'genre', 'Romance; History'); + }); + + it('omits description and genre when they sanitize to empty', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.mp3', { + title: 'Book', + author: 'Author', + description: ' \r\n ', + genres: [' ', ''], + }); + + const payloads = metadataPayloads(lastCallArgv()); + expect(payloads.some((p) => p.startsWith('description='))).toBe(false); + expect(payloads.some((p) => p.startsWith('genre='))).toBe(false); + }); + }); + describe('quote regression (#171) — embedded `"` flows through untouched', () => { const tricky = 'Test "Quoted" Book';