Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
7920f61
feat: add Library Import page (Sonarr-style)
tsolo4ever Mar 6, 2026
f9cc168
fix: resolve CI TypeScript errors in Library Import
tsolo4ever Mar 6, 2026
ab42671
fix: prefer detectedTitle over folderName for auto-search queries
tsolo4ever Mar 6, 2026
ac836d6
fix: use detectedAsin for auto-search when available
tsolo4ever Mar 6, 2026
6b87601
feat: add root folder unmatched file scanner
tsolo4ever Mar 5, 2026
e70c2ae
feat: read embedded MP4/M4B tags via ffprobe for unmatched scan
tsolo4ever Mar 5, 2026
c3a1491
fix: update RootFoldersController tests to pass IUnmatchedScanQueueSe…
tsolo4ever Mar 5, 2026
f41a720
feat: add configurable concurrency for unmatched scan
tsolo4ever Mar 5, 2026
cdfbe81
feat: cache unmatched scan results in memory, add explicit Scan button
tsolo4ever Mar 5, 2026
547ae0e
test: update RootFoldersController tests to pass ListenArrDbContext
tsolo4ever Mar 6, 2026
5c40de9
test: update tests for new constructor and search response shape
tsolo4ever Mar 6, 2026
56c715b
fix: unmatched scanner now excludes files already tracked via Audiobo…
tsolo4ever Mar 5, 2026
184aaee
fix: unmatched batch add passes destinationPath; filter stale scan cache
tsolo4ever Mar 6, 2026
621fa50
fix: flat root files each get own scan entry; load stores for dynamic…
tsolo4ever Mar 6, 2026
66b0e13
fix: unmatched scanner groups by title stem for author/flat folder la…
tsolo4ever Mar 6, 2026
fb71040
fix: series array handling and ASIN detection in Library Import search
tsolo4ever Mar 6, 2026
c09a544
fix: apply full folder+file naming when destination is a configured r…
tsolo4ever Mar 6, 2026
68cdeff
fix: register IUnmatchedScanQueueService in DI and add EF migration f…
tsolo4ever Mar 5, 2026
e50f776
fix: show destination path in Library Import footer
tsolo4ever Mar 6, 2026
ec2f601
fix: preserve numeric parens in title stem to split series books in s…
tsolo4ever Mar 6, 2026
d0b8f39
feat: add destination folder dropdown to Library Import footer
tsolo4ever Mar 6, 2026
fc49702
fix: handle 409 on existing books and add numeric filename hint for s…
tsolo4ever Mar 6, 2026
55d9472
fix: update BasePath on existing audiobooks before manual import
tsolo4ever Mar 6, 2026
3cae226
feat: prefer author-matched result in auto-search; highlight author m…
tsolo4ever Mar 6, 2026
db3beac
fix: resolve TS2322 undefined not assignable to null in pickBestMatch
tsolo4ever Mar 6, 2026
cb549d4
fix: enrich metadata from Audimeta before addToLibrary to populate au…
tsolo4ever Mar 6, 2026
b7b4741
fix: combine title+subtitle for unique series book paths
tsolo4ever Mar 6, 2026
57925e6
fix: ASIN auto-match always returns result; add scan polling fallback
tsolo4ever Mar 7, 2026
36a19ed
fix: sanitize series array in searchResult before library add
tsolo4ever Mar 7, 2026
529929f
fix: prevent sibling file mis-attribution in flat series folders; cle…
tsolo4ever Mar 7, 2026
3f4ef0a
fix: stop repeated author image lookup 404 flood
tsolo4ever Mar 7, 2026
1814219
fix: stop repeated author image lookup 404 flood
tsolo4ever Mar 7, 2026
1849b5b
fix: fallback to Audnexus when Audimeta author lookup returns 404
tsolo4ever Mar 7, 2026
53c0c18
fix: fallback to Audnexus when Audimeta author lookup returns 404
tsolo4ever Mar 7, 2026
6c05f3c
fix: preserve search matches on re-scan in library import
tsolo4ever Mar 8, 2026
3321e76
feat: embed ASIN tag into audio file on import via TagLibSharp
tsolo4ever Mar 6, 2026
ca06f35
fix: virtual scroller spacer missing height causes truncated book grid
tsolo4ever Mar 8, 2026
3d13b55
feat: restore localStorage persistence for import matches
tsolo4ever Mar 8, 2026
57ee344
fix: re-persist matches after re-scan so they survive page reload
tsolo4ever Mar 8, 2026
867c95a
fix: normalize searchResult.isbn string to array before POST /library…
tsolo4ever Mar 8, 2026
16ca502
chore: merge upstream/canary into feature/library-import-page
tsolo4ever Mar 9, 2026
dd2dee8
refactor: extract normalizeGenres helper in libraryImport store
tsolo4ever Mar 10, 2026
edc45c8
feat: library import UX improvements
tsolo4ever Mar 10, 2026
1dd2f1e
fix: scan controls stack vertically on mobile, button not full-width
tsolo4ever Mar 10, 2026
9cdff3b
fix: add TTL cleanup for completed scan jobs in UnmatchedScanQueueSer…
tsolo4ever Mar 10, 2026
8a8f15d
fix: skip reparse points during unmatched scan; remove dev db-reset f…
tsolo4ever Mar 10, 2026
8dcb07c
fix: path containment validation, separator boundary check, and proce…
tsolo4ever Mar 10, 2026
fe8be4f
fix: separator boundary in PathMetadataParser and N+1 root folder que…
tsolo4ever Mar 10, 2026
944810b
fix: use filename stem for search title instead of album tag (series …
tsolo4ever Mar 10, 2026
ab600f9
feat: add author field to search modal, pre-filled from detected author
tsolo4ever Mar 10, 2026
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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageVersion Include="SharpCompress" Version="0.36.0" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
Expand Down
12 changes: 10 additions & 2 deletions fe/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -291,10 +291,17 @@
<PhCalendar />
<span>Calendar</span>
</RouterLink>
<!-- <RouterLink to="/library-import" class="nav-item">
<RouterLink
to="/library-import"
class="nav-item"
@mouseenter="preload('library-import')"
@focus="preload('library-import')"
@touchstart.passive="preload('library-import')"
@click="closeMobileMenu"
>
<PhFolderOpen />
<span>Library Import</span>
</RouterLink> -->
</RouterLink>
</div>

<div class="nav-section">
Expand Down Expand Up @@ -485,6 +492,7 @@ import {
PhDownload,
PhCheckCircle,
PhList,
PhFolderOpen,
} from '@phosphor-icons/vue'
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useEventListener } from '@vueuse/core'
Expand Down
205 changes: 205 additions & 0 deletions fe/src/components/domain/audiobook/LibraryImportFooter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<template>
<div class="import-footer">
<!-- Left: input mode + rate limit warning -->
<div class="footer-left">
<label class="footer-label">
<select v-model="store.inputMode" class="mode-select">
<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">
<option v-for="f in props.folders" :key="f.id" :value="f.id">
{{ f.path }}
</option>
</select>
</label>

<div v-if="store.metadataFetchCount > 100" class="rate-limit-warning">
<PhWarning :size="14" />
{{ store.metadataFetchCount }} API lookups — rate limit: 150/window
</div>
</div>

<!-- Center: processing controls -->
<div class="footer-center">
<button
v-if="store.hasUnprocessedItems && !store.isProcessing"
class="btn btn-primary btn-sm"
@click="store.startProcessing()"
>
<PhPlay :size="14" />
Start Processing
</button>

<template v-if="store.isProcessing">
<PhSpinner class="ph-spin" :size="14" />
<span class="processing-label">
Processing {{ store.processedCount }} / {{ store.itemList.length }}…
</span>
<button class="btn btn-secondary btn-sm" @click="store.stopProcessing()">
<PhStop :size="14" />
Cancel
</button>
</template>
</div>

<!-- Right: import button -->
<div class="footer-right">
<span v-if="store.selectedCount > 0" class="selected-label">
{{ store.selectedCount }} selected
</span>
<button
class="btn btn-primary"
:disabled="store.selectedCount === 0 || store.isProcessing"
@click="handleImport"
>
<PhDownload :size="14" />
Import {{ store.selectedCount > 0 ? store.selectedCount : '' }} Book{{ store.selectedCount !== 1 ? 's' : '' }}
</button>
</div>
</div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { PhWarning, PhPlay, PhStop, PhSpinner, PhDownload } from '@phosphor-icons/vue'
import { useLibraryImportStore } from '@/stores/libraryImport'
import { useToast } from '@/services/toastService'
import type { RootFolder } from '@/types'

const props = defineProps<{ folders: RootFolder[] }>()

const store = useLibraryImportStore()
const toast = useToast()

const destinationFolderId = ref<number | null>(props.folders[0]?.id ?? null)
const destinationPath = computed(
() => props.folders.find((f) => f.id === destinationFolderId.value)?.path ?? '',
)

async function handleImport() {
const { imported, errors } = await store.importSelected(destinationPath.value)

if (imported > 0) {
let msg = `${imported} book${imported !== 1 ? 's' : ''} imported`
if (store.metadataFetchCount > 0) msg += ` · ${store.metadataFetchCount} metadata lookups`
toast.success('Import complete', msg)
}

if (errors.length > 0) {
toast.error('Import errors', `${errors.length} item${errors.length !== 1 ? 's' : ''} failed — check logs`)
}
}
</script>

<style scoped>
.import-footer {
position: sticky;
bottom: 0;
background: #1a1a1a;
border-top: 1px solid #333;
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
z-index: 10;
}

.footer-left {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
flex-wrap: wrap;
}

.footer-label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
color: #aaa;
white-space: nowrap;
}

.footer-to {
color: #888;
}

.mode-select {
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
color: #e0e0e0;
font-size: 0.82rem;
padding: 0.4rem 0.6rem;
height: var(--control-height, 40px);
box-sizing: border-box;
cursor: pointer;
}

.destination-select {
font-family: monospace;
font-size: 0.78rem;
max-width: 280px;
}

.rate-limit-warning {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 4px;
padding: 0.2rem 0.5rem;
}

.footer-center {
display: flex;
align-items: center;
gap: 0.6rem;
color: #888;
font-size: 0.82rem;
}

.processing-label {
color: #aaa;
white-space: nowrap;
}

.footer-right {
display: flex;
align-items: center;
gap: 0.75rem;
}

.selected-label {
font-size: 0.82rem;
color: #888;
white-space: nowrap;
}

@media (max-width: 640px) {
.import-footer {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
padding: 0.75rem 1rem;
}

.footer-left,
.footer-center,
.footer-right {
width: 100%;
justify-content: flex-start;
}

.destination-select {
max-width: 100%;
flex: 1;
}
}
</style>
Loading