Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
11 changes: 7 additions & 4 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,
action: action.value,
includeCompanionFiles: true,
Comment thread
therobbiedavis marked this conversation as resolved.
Outdated
cleanupEmptySourceFolders: inputMode.value === 'move',
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