Skip to content

Add OPF sidecar metadata and hardlink file placement support - #220

Open
pharrside89 wants to merge 5 commits into
kikootwo:mainfrom
pharrside89:main
Open

Add OPF sidecar metadata and hardlink file placement support#220
pharrside89 wants to merge 5 commits into
kikootwo:mainfrom
pharrside89:main

Conversation

@pharrside89

@pharrside89 pharrside89 commented Jun 8, 2026

Copy link
Copy Markdown

Summary

Adds configurable OPF sidecar metadata writing and hardlink-based file placement for organized audiobook imports.

This allows ReadMeABook to avoid duplicating large audiobook files when downloads and the audiobook library are on the same filesystem, while also writing metadata.opf sidecar files that Audiobookshelf can consume during library scans.

Closes: #68

Changes

  • Added OPF sidecar metadata writing during audiobook organization.
  • Added configurable file placement support:
    • Copy files
    • Hardlink files
  • Added configurable hardlink failure behavior:
    • Fall back to copy
    • Fail the import
  • Added GUI settings for:
    • Metadata Write Mode
    • File Placement Mode
    • Hardlink fallback behavior
  • Prevented invalid settings combinations where hardlinking is selected with embedded metadata writes.
    • Hardlink mode is only available when Metadata Write Mode is set to OPF sidecar.
    • Embedded metadata tagging creates a modified temporary file, so copy placement is required in that mode.
  • Kept environment variables as fallback/defaults for compatibility:
    • RMAB_METADATA_WRITE_MODE
    • RMAB_FILE_PLACEMENT_MODE
    • RMAB_HARDLINK_FALLBACK_MODE
  • Added automatic refresh behavior for My Requests while requests are in downloaded status, so the UI can update when items become available.

Validation

Tested in Docker with a shared /media mount containing both /media/downloads and /media/audiobooks.

Validated hardlink behavior by comparing device, inode, and link count between the download file and organized library file.

Expected hardlink result:

  • Same device
  • Same inode
  • Link count 2

Validated OPF sidecar creation in the organized audiobook directory by confirming metadata.opf was created.

Validated Audiobookshelf consumption of OPF metadata after library scan.

Validated the GUI guardrail:

  • OPF sidecar file + Hardlink files is allowed.
  • Embedded audio tags + Hardlink files is not allowed.
  • Embedded tags + OPF sidecar + Hardlink files is not allowed.

Notes

Hardlinking requires the download path and library path to be on the same filesystem.

For Docker deployments, this usually means mounting a shared parent path such as /media, then using subdirectories like /media/downloads and /media/audiobooks.

Using separate Docker bind mounts for downloads and audiobooks may prevent hardlinking, even when both paths point to the same NAS or host filesystem.

@kikootwo kikootwo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. It's a feature I've wanted for a while and the overall shape is right: a generic placeFile utility, a standalone OPF writer, and defaults that preserve existing behavior. I ran the full test suite on your branch and everything passes.

Requesting changes on a few things before this can go in:

  1. There's a real state bug in the settings UI (inline comment on PathsTab). Two updatePath calls in one handler clobber each other, so the guardrail silently reverts your write mode selection.
  2. "Fail the import" doesn't actually fail the import in every case (inline comment in file-organizer).
  3. Repo conventions: new files need the header comment linking to documentation, the docs need updating (documentation/phase3/file-organization.md and TABLEOFCONTENTS.md should cover the new config keys and the RMAB_* env vars), and the new utils need tests. file-placement and opf-writer are both pure and easy to cover, and there are examples to crib from in tests/utils.
  4. The useRequests changes are mostly unrelated to this feature (inline comment).

Smaller stuff inline. Also some diff noise worth cleaning up: the metadata tagging toggle block got re-indented for no functional reason and now sits at the wrong depth relative to its siblings, the Plex Format Coercion comment is duplicated, and both new files are missing trailing newlines.

One more note for the docs: hardlinked files share an inode with the seeded download, so we intentionally skip chmod on them (otherwise we'd be changing the seed file's permissions too). That means the fileChmod setting is quietly ignored in hardlink mode, which is worth a sentence in the UI help text.

updatePath('metadataWriteMode', nextMode);

if (paths.filePlacementMode === 'hardlink' && nextMode !== 'opf') {
updatePath('filePlacementMode', 'copy');

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has a stale-state bug. updatePath builds its object from the paths prop captured at render, and the parent onChange is a plain setSettings. React batches these two calls, so the second one wins with an object that doesn't include the first change. Net effect: with OPF + hardlink selected, picking "Embedded audio tags" flips placement to copy but silently reverts the write mode. It only sticks the second time you select it.

Build one merged object and call onChange once (or add a variant of updatePath that accepts multiple fields).

: filePlacementMode;

if (effectivePlacementMode === 'hardlink' && taggedFilePath) {
throw new Error('Hardlink mode is incompatible with embedded metadata tagging');

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This throw is unreachable: taggedFilePath forces effectivePlacementMode to 'copy' a few lines above, so the condition can never be true. Drop it, or move the check somewhere it can actually fire.

throw new Error('Hardlink mode is incompatible with embedded metadata tagging');
}

const placementResult = await placeFile(sourcePath, targetFilePath, {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Fail the import" doesn't reliably fail the import. When placeFile throws here, the catch below pushes to result.errors and moves on to the next file, and success is just audioFiles.length > 0. The "already exists, skipping" path earlier also pushes into audioFiles, so on a retry after a partial run the import succeeds even though hardlinks are still failing.

Either make a placement failure in fail mode abort the organization, or relabel the setting to match what it actually does.

);
}

const effectiveFilePlacementMode =

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues in this area. First, the coercion is silent: the client submitted hardlink and we store copy without telling anyone. The UI already guards this combination, so I'd rather return a 400 here than quietly store something different from what was sent.

Second, the upserts below always write, with || 'embedded' / || 'copy' defaults. Any PUT that omits these fields resets the stored modes to defaults, and once those DB rows exist they permanently override the RMAB_* env vars (DB wins over env everywhere we read these). Only upsert when the field is actually present in the request.

fileRenameTemplate?: string;
fileChmod?: string;
dirChmod?: string;
metadataWriteMode: 'embedded' | 'opf' | 'both' | 'disabled';

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'disabled' never appears in the API or stored config (the PUT validation rejects it). It's derived inside the organizer when tagging is off. Keep this union to the three real values and keep 'disabled' internal to file-organizer.

import fs from 'fs/promises';
import { copyFile } from './copy-file';

export type FilePlacementMode = 'copy' | 'hardlink' | 'move';

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'move' isn't reachable from any setting, and fs.rename throws EXDEV across filesystems anyway. I'd drop it until something actually needs it.

];

if (audiobook.narrator) {
lines.push(` <meta name="narrator" content="${escapeXml(audiobook.narrator)}"/>`);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure ABS picks this up? Audiobookshelf's OPF parser reads narrators from <dc:creator opf:role="nrt"> and ASIN from a dc:identifier with an ASIN scheme, and I don't believe it consumes or . Your validation may have covered title/author/series only. Worth emitting the role-based creator and the scheme-based identifier as well.

useSWRInfinite<RequestPage>(getKey, fetcher, {
revalidateFirstPage: true,
revalidateOnFocus: false,
revalidateOnFocus: true,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This flips a deliberate earlier choice and changes request-list traffic for every user, and it isn't related to OPF or hardlinks. Adding 'downloaded' to the active statuses makes sense alongside this feature, but please pull the revalidateOnFocus change into its own PR so it can be discussed on its own.

@pharrside89

Copy link
Copy Markdown
Author

Thanks for this. It's a feature I've wanted for a while and the overall shape is right: a generic placeFile utility, a standalone OPF writer, and defaults that preserve existing behavior. I ran the full test suite on your branch and everything passes.

Requesting changes on a few things before this can go in:

  1. There's a real state bug in the settings UI (inline comment on PathsTab). Two updatePath calls in one handler clobber each other, so the guardrail silently reverts your write mode selection.
  2. "Fail the import" doesn't actually fail the import in every case (inline comment in file-organizer).
  3. Repo conventions: new files need the header comment linking to documentation, the docs need updating (documentation/phase3/file-organization.md and TABLEOFCONTENTS.md should cover the new config keys and the RMAB_* env vars), and the new utils need tests. file-placement and opf-writer are both pure and easy to cover, and there are examples to crib from in tests/utils.
  4. The useRequests changes are mostly unrelated to this feature (inline comment).

Smaller stuff inline. Also some diff noise worth cleaning up: the metadata tagging toggle block got re-indented for no functional reason and now sits at the wrong depth relative to its siblings, the Plex Format Coercion comment is duplicated, and both new files are missing trailing newlines.

One more note for the docs: hardlinked files share an inode with the seeded download, so we intentionally skip chmod on them (otherwise we'd be changing the seed file's permissions too). That means the fileChmod setting is quietly ignored in hardlink mode, which is worth a sentence in the UI help text.

Hey, thanks for getting to this. I'll take a look and update asap. BTW I've been running this for the last month or so and its been working great!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Option to create OPF files/hardlink for audiobooks and ebooks

2 participants