Skip to content

feat(file-management): add symbolic link import action - #771

Open
sebbeke wants to merge 3 commits into
Listenarrs:canaryfrom
sebbeke:feature/symlink-import
Open

feat(file-management): add symbolic link import action#771
sebbeke wants to merge 3 commits into
Listenarrs:canaryfrom
sebbeke:feature/symlink-import

Conversation

@sebbeke

@sebbeke sebbeke commented Jul 19, 2026

Copy link
Copy Markdown

Summary

Adds a configurable Symbolic Link completed-file action for virtual, remote, and FUSE-backed storage workflows.

This allows Listenarr to import audiobooks without copying their contents locally when the source is exposed through systems such as NZBDav, rclone, Zurg, or other mounted storage providers.

This addresses the use case discussed in Discussion #334, where hardlinks are not possible because the source and destination are located on different filesystems.

Changes

Added

  • Added a new Symbolic Link file action to the backend.
  • Added the corresponding option to the frontend file-management settings.
  • Added support for regular source files and existing symbolic links.
  • Added handling for both absolute and relative symbolic-link targets.
  • Added tests covering successful and failed symbolic-link imports.

Changed

  • Existing source symlinks are resolved so the library entry can point directly to the underlying target instead of creating an unnecessary chain of links.
  • Symbolic-link imports keep the source file under the control of the download client.
  • Destination links are created safely to reduce the risk of replacing a valid existing file when link creation fails.

Motivation

The existing Hardlink/Copy action is not suitable for workflows where completed downloads are exposed through a separate filesystem, such as an rclone or WebDAV FUSE mount.

In those environments:

  1. Creating a hardlink fails because the source and destination are on different filesystems.
  2. Listenarr falls back to copying the file.
  3. The entire virtual audiobook is read from the remote provider and stored locally.

The new action creates a symbolic link instead, avoiding the full copy while still allowing applications such as Audiobookshelf to access the audiobook through the mounted source.

Example:

/data/media/audiobooks/Author/Book/book.m4b
    -> /data/debrid/torrents/Book/book.m4b

Supported workflows

This implementation is intended to support workflows including:

  • NZBDav with rclone
  • Real-Debrid with RDTClient and Zurg/rclone
  • WebDAV and FUSE-mounted storage
  • Other virtual filesystems that expose stable local paths

The symbolic-link target must remain available for the imported audiobook to remain accessible. Applications consuming the library must also have access to the target under the same absolute path.

Testing

The implementation was tested for:

  • importing a regular source file as a symbolic link;
  • preserving an existing absolute symbolic-link target;
  • resolving an existing relative symbolic-link target;
  • keeping the original source file or link intact;
  • handling an existing destination;
  • cleaning up after failed link creation;
  • ensuring symbolic-link imports do not fall back to copying file contents.

Notes

Related discussion: #334

Parts of this implementation were developed with AI assistance. All AI-assisted changes were reviewed and adjusted before committing, and the resulting implementation was tested against the scenarios described above.

Sebastiaan Jacobs added 2 commits July 19, 2026 23:30
Add a configurable symbolic link file action for virtual and FUSE-backed storage workflows.

Preserve existing symlink targets during import, avoid copying file contents, and keep source files under downloader control. Expose the new action in the frontend and add coverage for regular files, absolute links, relative links, and failed imports.

Supports use cases discussed in Listenarr Discussion Listenarrs#334, including NZBDav, rclone, FUSE, and Real-Debrid mounted storage.

Parts of this implementation were developed with the assistance of AI. All generated changes were reviewed, adjusted, and tested before committing.
Build the custom Listenarr image with GitHub Actions and publish it to GitHub Container Registry for linux/amd64.
@sebbeke
sebbeke requested a review from a team July 19, 2026 21:37
@m4bard

m4bard commented Jul 30, 2026

Copy link
Copy Markdown

Built 5d6d764 and drove the new action through manual-import on Linux, then checked the result at the filesystem level rather than by reading the files back. Content comparison cannot tell a link from a symlink from a copy, which is the gap #598 was opened about.

action source and library on result source
symlink one mount symlink to the source preserved
symlink separate mounts symlink to the source preserved
hardlink/copy one mount hardlink, shared inode, link count 2 preserved
hardlink/copy separate mounts copy after EXDEV preserved

The cross-mount symlink row is the one that matters, since that is the case a hardlink cannot serve and the reason this action exists. The bottom two rows are a regression check: the existing action behaves on your branch exactly as it does on canary.

One thing worth a line in the docs rather than a code change. The link records the path as the importing process sees it, so in a container it points at the container's path for the source:

/data/lib/.../The Valley of Fear.m4b -> /data/src/.../The Valley of Fear.m4b

Inside the container that resolves and stat -L reports the real size. Read from the host, or from another container mounting the same storage at different paths, it does not, because that path only exists in Listenarr's namespace. That is how absolute symlinks work rather than anything wrong here, but it is a sharp edge for the setups this targets: anyone pointing a player at the same library needs the source mounted at the same path there too. Since you are already touching the README, it is worth a sentence next to the setting.

It caught me out, for what it's worth. My first run judged the link with readlink -f from the host, called both cases broken, and looked like a real defect until I exec'd into the container and watched it resolve.

On merge order rather than the code: three of the thirteen files here also change on #717, which is FileMover.Copying.cs, DownloadImportService.cs and fe/src/types/index.ts. It reads as a rebase rather than a redo, since CopyFileAsync and HardlinkFileAsync both still exist on that branch and SymlinkFileAsync sits beside them in the same shape. I mention it only because two other PRs in this area turned out to be patching methods that branch deletes outright, which is a worse thing to discover after review than before.

You can re-run any of this yourself:

git clone https://github.com/m4bard/listenarr-testdata && cd listenarr-testdata
python3 -m venv .venv && .venv/bin/pip install -e .

git clone --depth 1 --branch feature/symlink-import \
    https://github.com/sebbeke/Listenarr.git /tmp/pr771
podman build --network=host -t listenarr-vet:pr771 /tmp/pr771

./tools/validate_import_action.sh localhost/listenarr-vet:pr771 --action symlink
./tools/validate_import_action.sh localhost/listenarr-vet:pr771 --action hardlink/copy

It generates a library, drives the import through the API, and exits non-zero if the destination is not what the action promised or the source went missing.

@therobbiedavis

Copy link
Copy Markdown
Collaborator

#717 adds strict symbolic-link/alias safety, pinned/no-follow publication primitives, durable directory ownership, and required native link-capability tests on Linux and Windows. The symbolic-link import action remains a useful distinct feature, but its current direct Directory.CreateDirectory + File.CreateSymbolicLink + overwrite publication path must be reworked on top of those contracts. Please rebase on #717 and route destination creation/publication through the ownership/mutation primitives and shared native capability attributes.

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.

3 participants