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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- **Library import:**
- Ability to add root folders while selecting the root folder from which we want to import files
- Ability to leave files in place when importing library
- Ability to set the desired monitoring for audiobooks added through library importation

## [0.2.70] - 2026-04-04

### Security
Expand Down
2 changes: 1 addition & 1 deletion fe/src/__tests__/libraryImport.store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe('library import store', () => {
expect(startManualImport).toHaveBeenCalledWith({
path: 'C:\\incoming',
mode: 'interactive',
inputMode: 'move',
action: 'move',
includeCompanionFiles: true,
cleanupEmptySourceFolders: true,
items: [
Expand Down
40 changes: 28 additions & 12 deletions fe/src/components/domain/audiobook/LibraryImportFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,33 @@
<div class="import-footer">
<div class="footer-left">
<label class="footer-label">
<select v-model="store.inputMode" class="mode-select" :disabled="isImporting">
Monitor:
<select v-model="store.monitor" class="mode-select" :disabled="isImporting">
<option value="all">All</option>
<option value="none">None</option>
</select>
</label>
<label class="footer-label">
On import:
<select v-model="store.action" class="mode-select" :disabled="isImporting">
<option value="none">Do nothing</option>
<option value="move">Move</option>
<option value="hardlink/copy">Hardlink / Copy</option>
</select>
<span class="footer-to">to:</span>
<select
v-model="destinationFolderId"
class="mode-select destination-select"
:disabled="isImporting"
>
<option v-for="f in props.folders" :key="f.id" :value="f.id">
{{ f.path }}
</option>
</select>
<div v-if="store.action != 'none'">
<label class="footer-label">to
<select
v-model="destinationFolderId"
class="mode-select destination-select"
:disabled="isImporting"
>
<option v-for="f in props.folders" :key="f.id" :value="f.id">
{{ f.path }}
</option>
</select>
</label>
</div>
<span v-else>Imported files will be left where they are</span>
</label>

<div v-if="store.metadataFetchCount > 100" class="rate-limit-warning">
Expand Down Expand Up @@ -81,7 +94,10 @@ const isImporting = ref(false)
const importingCount = ref(0)

const destinationPath = computed(
() => props.folders.find((f) => f.id === destinationFolderId.value)?.path ?? '',
() => {
if (store.action == 'none') return '';
return props.folders.find((f) => f.id === destinationFolderId.value)?.path ?? ''
}
)

const displayImportCount = computed(() =>
Expand Down
12 changes: 8 additions & 4 deletions fe/src/components/settings/RootFolderFormModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ import { useToast } from '@/services/toastService'
import type { RootFolder } from '@/types'

const { root } = defineProps<{ root?: RootFolder }>()
const emit = defineEmits(['close', 'saved'])
const emit = defineEmits<{
close: []
saved: [rootFolder: RootFolder]
}>()

const store = useRootFoldersStore()
const toast = useToast()
Expand Down Expand Up @@ -93,28 +96,29 @@ async function save() {
return
}
try {
var newRoot;
if (root?.id) {
// If path changed, show confirmation to choose whether to move files
if (form.value.path !== root.path) {
showConfirm.value = true
return
}
await store.update(root.id, {
newRoot = await store.update(root.id, {
id: root.id,
name: form.value.name,
path: form.value.path,
isDefault: form.value.isDefault,
})
toast.success('Success', 'Root folder updated')
} else {
await store.create({
newRoot = await store.create({
name: form.value.name,
path: form.value.path,
isDefault: form.value.isDefault,
})
toast.success('Success', 'Root folder created')
}
emit('saved')
emit('saved', newRoot)
} catch (e: unknown) {
const error = e as Error
toast.error('Error', error?.message || 'Failed to save root folder')
Expand Down
13 changes: 8 additions & 5 deletions fe/src/stores/libraryImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ 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 inputMode = ref<'move' | 'hardlink/copy'>('move')
const action = ref<'none' | 'move' | 'hardlink/copy'>('none')
const monitor = ref<'none' | 'all'>('all')
const metadataFetchCount = ref(0)
const importErrors = ref<string[]>([])

Expand Down Expand Up @@ -432,6 +433,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => {
: match.series,
}
const { audiobook } = await apiService.addToLibrary(metadata, {
monitored: monitor.value != 'none',
destinationPath: rootFolderPath,
searchResult: sanitizedMatch,
})
Expand Down Expand Up @@ -461,9 +463,9 @@ export const useLibraryImportStore = defineStore('libraryImport', () => {
await apiService.startManualImport({
path: item.folderPath,
mode: 'interactive',
inputMode: inputMode.value,
includeCompanionFiles: true,
cleanupEmptySourceFolders: inputMode.value === 'move',
action: action.value,
includeCompanionFiles: action.value !== 'none',
cleanupEmptySourceFolders: action.value === 'move',
items: item.sourceFiles.map((fullPath) => ({
fullPath,
matchedAudiobookId: audiobookId,
Expand Down Expand Up @@ -494,7 +496,8 @@ export const useLibraryImportStore = defineStore('libraryImport', () => {
scanStatus,
scanError,
lastScannedAt,
inputMode,
action,
monitor,
metadataFetchCount,
importErrors,
// Computed
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 @@ -306,7 +306,7 @@ export interface ApplicationSettings {
allowedFileExtensions: string[]
importBlacklistExtensions?: string[]
// Action to perform for completed downloads.
completedFileAction?: 'Move' | 'Copy' | 'Hardlink/Copy'
completedFileAction?: 'None' | 'Move' | 'Copy' | 'Hardlink/Copy'
// Show completed external downloads (torrents/NZBs) in the Activity view
showCompletedExternalDownloads?: boolean
// Failed download handling
Expand Down Expand Up @@ -893,7 +893,7 @@ export interface ManualImportRequestItem {
export interface ManualImportRequest {
path: string
mode?: 'automatic' | 'interactive'
inputMode?: 'move' | 'copy' | 'hardlink/copy'
action?: 'none' | 'move' | 'copy' | 'hardlink/copy'
includeCompanionFiles?: boolean
cleanupEmptySourceFolders?: boolean
items?: ManualImportRequestItem[]
Expand Down
48 changes: 43 additions & 5 deletions fe/src/views/library/LibraryImportView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@
</select>
</div>

<button
class="btn btn-secondary btn-sm"
:disabled="store.scanStatus === 'scanning'"
@click="openAddRootFolder"
>
<PhFolderPlus :size="15" />
Choose another folder...
</button>

<button
class="btn btn-primary btn-sm"
:disabled="!selectedFolderId || store.scanStatus === 'scanning'"
Expand Down Expand Up @@ -175,6 +184,12 @@
:folders="rootFoldersStore.folders"
/>
</div>

<RootFolderFormModal
v-if="addRootFolder"
@saved="refreshRootFolders"
@close="closeAddRootFolder"
/>
</template>

<script setup lang="ts">
Expand All @@ -187,6 +202,7 @@ import {
PhArrowDown,
PhArrowUp,
PhArrowsDownUp,
PhFolderPlus,
} from '@phosphor-icons/vue'
import { useLibraryImportStore } from '@/stores/libraryImport'
import { useRootFoldersStore } from '@/stores/rootFolders'
Expand All @@ -202,6 +218,8 @@ import {
type LibraryImportSortDirection,
type LibraryImportSortKey,
} from '@/utils/libraryImportTable'
import RootFolderFormModal from '@/components/settings/RootFolderFormModal.vue'
import type { RootFolder } from '@/types'

const COLUMN_WIDTH_STORAGE_KEY = 'listenarr.libraryImport.columnWidths.v1'
const MAX_COLUMN_WIDTH = 960
Expand All @@ -216,6 +234,7 @@ const sortKey = ref<LibraryImportSortKey>('folder')
const sortDirection = ref<LibraryImportSortDirection>('asc')
const columnWidths = ref<LibraryImportColumnWidths>({ ...DEFAULT_LIBRARY_IMPORT_COLUMN_WIDTHS })
const resizingColumn = ref<LibraryImportResizableColumnKey | null>(null)
const addRootFolder = ref<boolean>(false)

const sortOptions: Array<{ value: LibraryImportSortKey; label: string }> = [
{ value: 'folder', label: 'Book' },
Expand Down Expand Up @@ -271,18 +290,18 @@ onMounted(async () => {
await store.initFromRootFolder(defaultFolder.id)
}

const action = configStore.applicationSettings?.completedFileAction
store.inputMode = action === 'Move' || !action ? 'move' : 'hardlink/copy'
store.action = 'none'
})

onBeforeUnmount(() => {
stopResize()
})

async function onFolderChange() {
if (!selectedFolderId.value) return
store.stopProcessing()
await store.initFromRootFolder(selectedFolderId.value)
if (selectedFolderId.value) {
store.stopProcessing()
await store.initFromRootFolder(selectedFolderId.value)
}
}

async function startScan() {
Expand Down Expand Up @@ -404,6 +423,25 @@ function persistColumnWidths() {
// Non-fatal: resizing still works for the current session.
}
}

function openAddRootFolder() {
addRootFolder.value = true
}

function closeAddRootFolder() {
addRootFolder.value = false
}

async function refreshRootFolders(newFolder: RootFolder) {
closeAddRootFolder()

await rootFoldersStore.load()

if (newFolder) {
selectedFolderId.value = newFolder.id
await store.initFromRootFolder(newFolder.id)
}
}
</script>

<style scoped>
Expand Down
5 changes: 3 additions & 2 deletions listenarr.api/Controllers/LibraryController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
using System.Text.RegularExpressions;
using System.Security.Cryptography;
using System.Text;
using Listenarr.Domain.Utils;

namespace Listenarr.Api.Controllers
{
Expand Down Expand Up @@ -2327,7 +2328,7 @@ private static bool PathsEqual(string? left, string? right)

private static bool IsSamePathOrWithin(string path, string rootPath)
{
return PathsEqual(path, rootPath) || FileUtils.IsPathWithinRoot(path, rootPath);
return PathsEqual(path, rootPath) || FileUtils.IsPathInsideOf(path, rootPath);
}

private static bool IsFilesystemRoot(string? path)
Expand Down Expand Up @@ -2776,7 +2777,7 @@ public async Task<IActionResult> ScanAudiobookFiles(int id, [FromBody] ScanReque
{
try
{
var jobId = await _scanQueueService.EnqueueScanAsync(id, request?.Path);
var jobId = await _scanQueueService.EnqueueScanAsync(audiobook, request?.Path);
_logger.LogInformation("Enqueued scan job {JobId} for audiobook {AudiobookId}", jobId, id);

// Broadcast initial job status via SignalR so clients can show queued state
Expand Down
Loading
Loading