Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion documentation/integrations/audible.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 20 additions & 2 deletions documentation/phase3/file-organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Result: Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/
2. Identify audiobook files (.m4b, .m4a, .mp3, .mp4, .aa, .aax, .flac, .ogg) - supports both directories and single files
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. **Tag metadata** (if enabled, before the move) - writes correct title, author, narrator, ASIN to tagged copies of the audio files
5. **Tag metadata** (if enabled, before the move) - writes correct title, author, narrator, ASIN, series, description, genre to tagged copies of the audio files
6. **ASIN step** - adds the MP4 freeform `ASIN` to the file about to be placed, when RMAB wrote it (see MP4 Freeform Tags), then **copy** files (not move - originals stay for seeding)
7. Copy cover art if found, else download from Audible. `coverArtUrl` must be a cached thumbnail `/api/cache/thumbnails/<file>` (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)
Expand Down Expand Up @@ -109,6 +109,22 @@ Result: Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/
- M4B/M4A/MP4: iTunes freeform item `----` (mean `com.apple.iTunes`, name `ASIN`), written after ffmpeg (see MP4 Freeform Tags)
- MP3: ID3v2 `TXXX:ASIN`; FLAC: Vorbis comment `ASIN`
- Read back as ffprobe format tag `ASIN`; Audiobookshelf maps it (`asin`, case-insensitive) to `tagASIN` → book `asin`
- Description - publisher summary; raw input first cut to `MAX_RAW_DESCRIPTION_LENGTH` (4x the cap, UTF-16 units, `truncateRawText`, surrogate-safe); non-whitespace C0 controls dropped, each whitespace run (space/tab/CR/LF/VT/FF/U+2028/U+2029) → one space (linear); capped at `MAX_DESCRIPTION_BYTES` (16 KiB UTF-8, `capUtf8Bytes`: last word boundary + `…`; one argv string each)
- M4B/M4A/MP4: `description` (`desc`) + `synopsis` (`ldes`), each capped
- MP3: `description` (TXXX); FLAC: `comment` (= Vorbis `DESCRIPTION`; replaces the source's DESCRIPTION/COMMENT, which ffmpeg reads as one `comment`; a `description` key would add a second one, read as "old;new")
- `genre` - top-level genres (`primaryGenres`, no sub-genre tags) joined with `; `; `/` or `;` in a name → ` & ` (split/trim/join; Audiobookshelf splits on `/`, `//`, `;`)
- M4B/M4A/MP4 only: `media_type=2` (`stik` Audiobook), `gapless_playback=1` (`pgap`) - always written, before `-movflags +faststart`
- Fixed set, no per-field settings; all under `metadata_tagging_enabled`

**Tag details lookup** (`src/lib/processors/organize-tag-details.ts`):
- Processor reads `metadata_tagging_enabled` once (`readMetadataTaggingEnabled`), passes it to the lookup and `organize()` (`placement.metadataTaggingEnabled`); unreadable → undefined: no lookup, `organize()` reads it itself
- Only when tagging is on; one `getAudiobookDetails(asin)` per import (`createAudibleDetailsLoader`), shared with the missing-series backfill
- Series known (lookup serves tags only): raced against `TAG_DETAILS_TIMEOUT_MS` (15 s); series backfill awaits it in full (unchanged)
- Description: Audible summary (Audnexus `summary`) → stored `Audiobook.description` fallback (client text: bounded by `truncateRawText`, then `stripHtml` like the lookup)
- Started right after the switch read, awaited just before `organize()` (setup reads run meanwhile; a no-op handler keeps a rejection handled if setup throws first)
- A failed ffmpeg run reports `describeFfmpegFailure` (stderr tail, never the argv, so no description in job logs)
- Lookup failure or timeout is non-fatal (warning): stored description, no genres
- Genres/description are tags only (not path template variables); ebooks unchanged

**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
Expand Down Expand Up @@ -139,7 +155,7 @@ exiftool "audiobook.m4b" | grep -i asin

**Verification:** before anything is written, the new moov's chunk offsets = offsets computed from the input, chunk sizes unchanged, each tag present exactly once; after the write and before rename: output size and top-level layout as planned, written moov bytes = the verified moov, chunk bytes equal input's (every chunk of non-audio tracks up to 1 MiB each, 64 spread audio chunks × 4 KiB), ffprobe reads every tag. All handles closed before rename (Windows).

**Call site (one):** `file-organizer.ts`, per audio file right before placement, on the file about to be placed when RMAB wrote it: the tagged copy (`${file}.tmp`), else the merged file (tagging off, or the tagging pass failed on it). Never on a download's own file (tagging off, no merge → no ASIN in MP4s). It runs after the merge's own checks. The merger and tagger never write the ASIN; `metadata_tagging_enabled` is read once per `organize()` (unreadable → tagging off, warning + `result.errors` "Metadata tagging skipped: could not read setting (<msg>)"). A written ASIN logs "Embedded ASIN <asin> in <file>" (info); a refused one logs a warning.
**Call site (one):** `file-organizer.ts`, per audio file right before placement, on the file about to be placed when RMAB wrote it: the tagged copy (`${file}.tmp`), else the merged file (tagging off, or the tagging pass failed on it). Never on a download's own file (tagging off, no merge → no ASIN in MP4s). It runs after the merge's own checks. The merger and tagger never write the ASIN; `metadata_tagging_enabled` is read once per `organize()`, or taken from `placement.metadataTaggingEnabled` (unreadable → tagging off, warning + `result.errors` "Metadata tagging skipped: could not read setting (<msg>)"). A written ASIN logs "Embedded ASIN <asin> in <file>" (info); a refused one logs a warning.

**Resources:**
- Disk: the merge check (`TEMP_DIR`) counts the estimated merged size × (1 merged file + 1 post-step copy when the book has an ASIN + 1 tagged copy when tagging is on): 1x-3x. A tagged single file with an ASIN needs 2x its size next to the download (tagged copy + post-step copy)
Expand Down Expand Up @@ -172,7 +188,9 @@ exiftool "audiobook.m4b" | grep -i asin
**Tech Stack:**
- ffmpeg (system dependency - included in Docker image)
- `src/lib/utils/metadata-tagger.ts` - Tagging utility
- `src/lib/utils/metadata-text.ts` - text helpers: `sanitizeMetadataValue`, `sanitizeMultilineValue`, `sanitizeGenreName`, `truncateRawText`, `capUtf8Bytes`, `describeFfmpegFailure`, `MAX_DESCRIPTION_BYTES`, `MAX_RAW_DESCRIPTION_LENGTH`
- 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`)
Expand Down
21 changes: 21 additions & 0 deletions e2e/specs/regressions/208-asin-tag.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
* 2. multi-file mp3, chapter merging on, tagging off (on the merged file)
* 3. multi-file mp3, chapter merging on, tagging on (on the tagged copy of the merge)
* Each also checks the placed file is moov first (faststart) through Audiobookshelf's container.
* With tagging on (1 and 3) it also checks the #397 tags as Audiobookshelf reads them: the book's
* genres array is the genre tag split on "; " (names like "Science Fiction & Fantasy" kept whole),
* and the book has one description, the tag's.
* Requests all three fixture books (phm too, like smoke): run with --isolate-specs next to other specs.
*/

Expand Down Expand Up @@ -87,6 +90,8 @@ async function importAndScan(scenario: string, bookKey: string, label: string) {
numAudioFiles: audio.length,
metaTags: audio[0]?.metaTags,
bookAsin: item.media.metadata.asin,
bookGenres: item.media.metadata.genres as string[],
bookDescription: item.media.metadata.description as string | null,
chapters: item.media.chapters?.length,
embeddedCoverArt: audio[0]?.embeddedCoverArt ?? null,
layout: audio[0] ? topLevelTypes(audio[0].metadata.path) : [],
Expand All @@ -104,10 +109,25 @@ function expectAsinInFile(book: Book, e: Awaited<ReturnType<typeof importAndScan
expect(e.layout.slice(0, 2), 'moov first (faststart)').toEqual(['ftyp', 'moov']);
}

/** #397: Audiobookshelf splits the genre tag into the book's genres and shows the tag's one description. */
function expectTagDetails(e: Awaited<ReturnType<typeof importAndScan>>['evidence']) {
const tagGenre = e.metaTags?.tagGenre as string | undefined;
expect(tagGenre, 'genre tag written').toBeTruthy();
const parts = tagGenre!.split('; ');
expect(parts.length, 'more than one top-level genre').toBeGreaterThan(1);
expect(e.bookGenres, 'Audiobookshelf genres = the tag split on "; "').toEqual(parts);
for (const genre of e.bookGenres) expect(genre).toMatch(/^[^;/]+$/);
expect(e.bookGenres.some((g) => g.includes(' & ')), 'an "A & B" genre stays one genre').toBe(true);
const tagDescription = e.metaTags?.tagDescription as string | undefined;
expect(tagDescription, 'description tag written').toBeTruthy();
expect(e.bookDescription, 'one description, the tag\'s').toBe(tagDescription);
}

test('#208: single m4b, tagging on: the tagged m4b carries the ASIN', async () => {
await withSettings({ chapter_merging_enabled: 'false', metadata_tagging_enabled: 'true' }, async () => {
const { book, evidence } = await importAndScan('asin-m4b', 'martian', 'single m4b');
expectAsinInFile(book, evidence, 2);
expectTagDetails(evidence);
});
});

Expand All @@ -122,5 +142,6 @@ test('#208: chapter merge, tagging on: the merged and tagged m4b carries the ASI
await withSettings({ chapter_merging_enabled: 'true', metadata_tagging_enabled: 'true' }, async () => {
const { book, evidence } = await importAndScan('asin-merge-tagged', 'phm', 'merge, tagging on');
expectAsinInFile(book, evidence, 3);
expectTagDetails(evidence);
});
});
24 changes: 24 additions & 0 deletions src/lib/integrations/audible.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 as unknown)
.filter((name): name is string => typeof name === 'string' && name.trim() !== '') ?? [];
const primaryGenres =
primaryGenreNames.length > 0 ? [...new Set(primaryGenreNames)] : undefined;

let series: string | undefined;
let seriesPart: string | undefined;
let seriesAsin: string | undefined;
Expand Down Expand Up @@ -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,
Expand All @@ -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<string | { name?: unknown; type?: unknown } | null>)
.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;
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 16 additions & 3 deletions src/lib/processors/organize-files.processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from './organize-evidence';
import { getAudibleService } from '../integrations/audible.service';
import { addAutoBlock } from '../services/blocklist.service';
import { createAudibleDetailsLoader, readMetadataTaggingEnabled, resolveTagDetails } from './organize-tag-details';

/**
* Process organize files job
Expand Down Expand Up @@ -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<string, any> = {};
Expand Down Expand Up @@ -187,6 +190,13 @@ 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'}`);

// One read of the tagging switch: the tag details lookup and the organizer both use it
const metadataTaggingEnabled = await readMetadataTaggingEnabled(logger);
// Started now, awaited just before organize(): the setup below does not wait on Audible.
// The no-op handler keeps a rejection from going unhandled if that setup throws first.
const tagDetailsPending = resolveTagDetails(audiobook.description, loadAudibleDetails, metadataTaggingEnabled, logger);
tagDetailsPending.catch(() => undefined);

// Get file organizer (reads media_dir from database config)
const organizer = await getFileOrganizer();

Expand All @@ -211,6 +221,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
template: fileRenameTemplateConfig?.value || '{title}',
};

const tagDetails = await tagDetailsPending;

// Organize files (pass template, logger, and rename config to file organizer)
const result = await organizer.organize(
downloadPath,
Expand All @@ -223,12 +235,13 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
year,
series,
seriesPart,
...tagDetails,
},
template,
jobId ? { jobId, context: 'FileOrganizer' } : undefined,
renameConfig,
selectedFiles,
{ compareExisting }
{ compareExisting, metadataTaggingEnabled }
);

if (!result.success) {
Expand Down
Loading
Loading