Skip to content
Open
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
65 changes: 65 additions & 0 deletions .github/workflows/build-container.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Build Listenarr container

on:
workflow_dispatch:

push:
branches:
- feature/symlink-import
tags:
- "v*"

permissions:
contents: read
packages: write

env:
REGISTRY: ghcr.io

jobs:
build:
name: Build and publish container
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Determine image name
id: image
shell: bash
run: |
IMAGE_NAME="${GITHUB_REPOSITORY,,}"
echo "name=${REGISTRY}/${IMAGE_NAME}" >> "$GITHUB_OUTPUT"

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Generate image metadata
id: metadata
uses: docker/metadata-action@v5
with:
images: ${{ steps.image.outputs.name }}
tags: |
type=raw,value=symlink-latest,enable={{is_default_branch}}
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=sha-

- name: Build and publish image
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,26 @@ Supported download clients:
- Automatic metadata fetching
- Library management options

#### Completed File Action

Choose how completed downloads are placed into the library (Settings → File Management → *Completed File Action*):

- **Move** – move the file into the library and remove it from the client's folder.
- **Copy** – copy the file into the library and leave the original in place.
- **Hardlink/Copy** – create a hardlink into the library (falls back to a copy when source and destination are on different filesystems).
- **Symbolic Link** (`symlink`) – create a symbolic link in the library instead of copying the file. The source content is never read or copied and the original source is left untouched. This is primarily useful with virtual filesystems such as **NZBDav**, **rclone** and other WebDAV/FUSE mounts, where a hardlink is impossible (cross-filesystem) and a copy would download the entire file.

When the source is itself a symlink, Listenarr reads its target and links the library entry **directly** to the final target, avoiding a symlink chain. Both absolute and relative link targets are supported (relative targets are resolved against the source symlink's directory).

> **Important:** the source and destination paths must stay reachable for the link to work. Because absolute link targets are preserved as-is, the underlying path (e.g. the NZBDav/rclone mount) must be mounted at the **same absolute path** in every container that reads the library — both Listenarr and your audiobook player (e.g. Audiobookshelf). The link will break whenever the source path (mount) is unavailable.
>
> Example — both containers must expose identical absolute paths:
>
> ```text
> Listenarr: /mnt/nzbdav + /data/media/audiobooks
> Audiobookshelf: /mnt/nzbdav + /data/media/audiobooks
> ```

## API Endpoints

### Search
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<option value="none">Do nothing</option>
<option value="move">Move</option>
<option value="hardlink/copy">Hardlink / Copy</option>
<option value="symlink">Symbolic Link</option>
</select>
<div v-if="store.action != 'none'">
<label class="footer-label"
Expand Down
3 changes: 2 additions & 1 deletion fe/src/components/feedback/ManualImportModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@
<option value="">Select Import Mode</option>
<option value="move">Move</option>
<option value="hardlink/copy">Hardlink/Copy</option>
<option value="symlink">Symbolic Link</option>
</select>

<!-- Show Interactive/Automatic Import when browser is open and not in preview mode -->
Expand Down Expand Up @@ -348,7 +349,7 @@ const selectedPath = ref(props.initialPath || '')
const loading = ref(false)
const browserMode = ref(false)
const inputField = ref<string>('')
const action = ref<'move' | 'hardlink/copy' | ''>('')
const action = ref<'move' | 'hardlink/copy' | 'symlink' | ''>('')

const showPreview = ref(false)
interface PreviewItem {
Expand Down
16 changes: 11 additions & 5 deletions fe/src/components/feedback/UnmatchedFilesModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -250,14 +250,20 @@ const destinationFolder = computed(
)

const fileAction = computed(() => configStore.applicationSettings?.completedFileAction ?? 'copy')
const fileInputMode = computed<'move' | 'copy' | 'hardlink/copy'>(() =>
fileAction.value === 'move' || fileAction.value === 'copy' || fileAction.value === 'hardlink/copy'
const fileInputMode = computed<'move' | 'copy' | 'hardlink/copy' | 'symlink'>(() =>
fileAction.value === 'move' ||
fileAction.value === 'copy' ||
fileAction.value === 'hardlink/copy' ||
fileAction.value === 'symlink'
? fileAction.value
: 'copy',
)
const fileActionLabel = computed(() =>
fileInputMode.value === 'copy' || fileInputMode.value === 'hardlink/copy' ? 'Copy to' : 'Move to',
)
const fileActionLabel = computed(() => {
if (fileInputMode.value === 'symlink') return 'Link to'
return fileInputMode.value === 'copy' || fileInputMode.value === 'hardlink/copy'
? 'Copy to'
: 'Move to'
})

let jobId = ''
let offSignalR: (() => void) | null = null
Expand Down
3 changes: 2 additions & 1 deletion fe/src/components/settings/FileManagementSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@

<FormRow
label="Completed File Action"
help="Choose whether completed downloads should be moved into the library output path or copied and left in the client's folder."
help="Choose whether completed downloads should be moved into the library output path or copied and left in the client's folder. Symbolic Link creates a link instead of copying the file — the source and destination must stay reachable, and the link breaks if the source path disappears (useful for NZBDav, rclone and other virtual filesystems)."
>
<select
:value="settings.completedFileAction"
Expand All @@ -106,6 +106,7 @@
<option value="move">Move</option>
<option value="copy">Copy</option>
<option value="hardlink/copy">Hardlink/Copy</option>
<option value="symlink">Symbolic Link</option>
</select>
</FormRow>

Expand Down
2 changes: 1 addition & 1 deletion fe/src/stores/libraryImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => {
const scanStatus = ref<'idle' | 'scanning' | 'done' | 'error'>('idle')
const scanError = ref<string | null>(null)
const lastScannedAt = ref<string | null>(null)
const action = ref<'none' | 'move' | 'hardlink/copy'>('none')
const action = ref<'none' | 'move' | 'hardlink/copy' | 'symlink'>('none')
const monitor = ref<'none' | 'all'>('all')
const metadataFetchCount = ref(0)
const importErrors = ref<string[]>([])
Expand Down
4 changes: 2 additions & 2 deletions fe/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ export interface ApplicationSettings {
allowedFileExtensions: string[]
importBlacklistExtensions?: string[]
// Action to perform for completed downloads.
completedFileAction?: 'none' | 'move' | 'copy' | 'hardlink/copy'
completedFileAction?: 'none' | 'move' | 'copy' | 'hardlink/copy' | 'symlink'
// Show completed external downloads (torrents/NZBs) in the Activity view
showCompletedExternalDownloads?: boolean
// Failed download handling
Expand Down Expand Up @@ -934,7 +934,7 @@ export interface ManualImportRequestItem {
export interface ManualImportRequest {
path: string
mode?: 'automatic' | 'interactive'
action?: 'none' | 'move' | 'copy' | 'hardlink/copy'
action?: 'none' | 'move' | 'copy' | 'hardlink/copy' | 'symlink'
includeCompanionFiles?: boolean
cleanupEmptySourceFolders?: boolean
items?: ManualImportRequestItem[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@ public async Task<List<ImportResult>> ImportDownloadFilesAsync(

files.AddRange(await archiveImportExtractor.ExtractAsync(archives));

// We cannot hardlink to temporary files
if (archives.Count > 0 && completedFileAction == FileAction.HardlinkCopy)
// We cannot hardlink or symlink to temporary extracted files
if (archives.Count > 0 && completedFileAction is FileAction.HardlinkCopy or FileAction.SymbolicLink)
{
logger.LogWarning($"Audiobook {audiobook.Id} contains archives thus {completedFileAction} mode is impossible: Completed action switched to copy");
completedFileAction = FileAction.Copy;
logger.LogWarning($"Audiobook {audiobook.Id} contains archives thus Hard link mode is impossible: Completed action switched to copy");
}
}

Expand Down
4 changes: 3 additions & 1 deletion listenarr.domain/Audiobooks/Enumerations/FileAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public enum FileAction
[JsonStringEnumMemberName("copy")]
Copy = 2,
[JsonStringEnumMemberName("hardlink/copy")]
HardlinkCopy = 3
HardlinkCopy = 3,
[JsonStringEnumMemberName("symlink")]
SymbolicLink = 4
}
}
106 changes: 106 additions & 0 deletions listenarr.infrastructure/FileSystem/FileMover.Copying.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,110 @@ public async Task<bool> HardlinkFileAsync(string sourceFile, string destFile)
}
}

public async Task<bool> SymlinkFileAsync(string sourceFile, string destFile)
{
try
{
// Ensure destination directory exists
var destDir = Path.GetDirectoryName(destFile) ?? string.Empty;
if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir))
{
Directory.CreateDirectory(destDir);
}

// Determine the link target. If the source is itself a symlink, point the new
// library link directly at its target so we never build a symlink chain. Relative
// link targets are resolved against the directory that holds the source symlink.
// A regular source file is linked to via its absolute path. Resolving the target
// never reads the file's content.
var linkTarget = ResolveSymlinkTarget(sourceFile);

// If the destination is already a symlink pointing at the same target there is
// nothing to do. Comparing link targets inspects only the link metadata and never
// reads file content, so a correct existing link is left untouched.
var existingTarget = new FileInfo(destFile).LinkTarget;
if (existingTarget != null && string.Equals(existingTarget, linkTarget, StringComparison.Ordinal))
{
LogMutation(FileMutationOutcome.Skipped, FileAction.SymbolicLink, sourceFile, destFile, "Destination already links to the same target");
return true;
}

// Safe ordering: create the symlink under a temporary name in the destination
// directory first, then atomically rename it onto the destination. This ensures an
// existing valid destination is never removed until a confirmed replacement is ready.
// File.CreateSymbolicLink never reads the source content and there is deliberately
// no copy/hardlink fallback: the source file must never be fully read.
// Use Path.GetFileName to strip any separators and Path.Join (not Path.Combine) so a
// rooted temp name cannot escape the destination directory.
var tempDestName = Path.GetFileName("." + Path.GetFileName(destFile) + "." + Guid.NewGuid().ToString("N") + ".tmp");
var tempDest = Path.Join(destDir, tempDestName);

try
{
File.CreateSymbolicLink(tempDest, linkTarget);

// Symlink created — atomically replace the destination.
File.Move(tempDest, destFile, overwrite: true);
LogMutation(FileMutationOutcome.Success, FileAction.SymbolicLink, sourceFile, destFile, $"Linked to {linkTarget}");
return true;
}
catch (Exception linkEx) when (linkEx is not (OperationCanceledException or OutOfMemoryException or StackOverflowException))
{
// Robust cleanup: File.Exists returns false for a broken symlink, so the leftover
// temp link is removed unconditionally rather than guarded by File.Exists.
TryDeleteLink(tempDest);
LogMutation(FileMutationOutcome.Failed, FileAction.SymbolicLink, sourceFile, destFile, linkEx.Message);
_logger.LogError(linkEx, "Symbolic link failed: {Source} -> {Dest}", sourceFile, destFile);
return false;
}
}
catch (Exception ex) when (ex is not (OperationCanceledException or OutOfMemoryException or StackOverflowException))
{
LogMutation(FileMutationOutcome.Failed, FileAction.SymbolicLink, sourceFile, destFile, ex.Message);
_logger.LogError(ex, "Symbolic link failed: {Source} -> {Dest}", sourceFile, destFile);
return false;
}
}

/// <summary>
/// Determines the target a new symbolic link should point at. When <paramref name="sourceFile"/>
/// is itself a symlink its own target is returned (resolving relative targets against the
/// source's directory) so no symlink chain is created; otherwise the absolute source path is used.
/// </summary>
private static string ResolveSymlinkTarget(string sourceFile)
{
var linkTarget = new FileInfo(sourceFile).LinkTarget;
if (string.IsNullOrEmpty(linkTarget))
{
// Regular file: link directly to its absolute path.
return Path.GetFullPath(sourceFile);
}

// Absolute targets are preserved as-is so they keep resolving under a shared container
// mount path; relative targets are resolved against the directory holding the source link.
if (Path.IsPathRooted(linkTarget))
{
return linkTarget;
}

var sourceDir = Path.GetDirectoryName(sourceFile) ?? string.Empty;
return Path.GetFullPath(Path.Combine(sourceDir, linkTarget));
}

private void TryDeleteLink(string path)
{
// File.Delete removes the symlink itself (not its target) and does not throw when the
// path is absent, so it safely cleans up even a broken temporary link.
try
{
File.Delete(path);
}
catch (Exception ex) when (ex is not (OperationCanceledException or OutOfMemoryException or StackOverflowException))
{
// best-effort temp cleanup; ignore non-critical failures
}
}

private void CopyDirRecursive(string src, string dst)
{
Directory.CreateDirectory(dst);
Expand Down Expand Up @@ -239,6 +343,8 @@ public async Task<bool> PerformActionOn(FileAction action, string source, string
return await HardlinkFileAsync(source, destination);
case FileAction.Copy:
return await CopyFileAsync(source, destination);
case FileAction.SymbolicLink:
return await SymlinkFileAsync(source, destination);
}

// Unhandled action: We are unable to fulfill the request
Expand Down
Loading