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
22 changes: 15 additions & 7 deletions fe/src/__tests__/AudiobookDetailView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { useLibraryStore } from '@/stores/library'
import { useScanNotificationsStore } from '@/stores/scanNotifications'
import { useFilesystemReadinessStore } from '@/stores/filesystemReadiness'
import { apiService, ensureImageCached } from '@/services/api'
import { signalRService } from '@/services/signalr'
import AudiobookDetailViewCmp from '@/views/library/AudiobookDetailView.vue'
const routerPushMock = vi.fn()
// Mock useRoute to provide params for the detail view
Expand All @@ -38,6 +37,7 @@ vi.mock('@/services/api', () => ({
getImageUrl: vi.fn((url: string) => url || 'https://via.placeholder.com/300x450?text=No+Image'),
getQualityProfiles: vi.fn(async () => []),
getLibrary: vi.fn(async () => []),
getAudiobook: vi.fn(async () => undefined),
scanAudiobook: vi.fn(),
getWeakStorageMissingFiles: vi.fn(async () => ({ items: [] })),
confirmWeakStorageMissingFiles: vi.fn(),
Expand Down Expand Up @@ -329,10 +329,7 @@ describe('AudiobookDetailView image recache behavior', () => {
const wrapper = mount(AudiobookDetailViewCmp, { global: { plugins: [pinia] } })
await new Promise((resolve) => setTimeout(resolve, 10))

const scanCallback = vi.mocked(signalRService.onScanJobUpdate).mock.calls[0]?.[0] as
| ((job: { audiobookId: number; status: string }) => void)
| undefined
expect(scanCallback).toBeDefined()
const scanNotificationsStore = useScanNotificationsStore()

let resolveOlder!: (value: {
scanToken: string
Expand All @@ -349,8 +346,19 @@ describe('AudiobookDetailView image recache behavior', () => {
.mockImplementationOnce(() => older)
.mockImplementationOnce(() => newer)

scanCallback!({ audiobookId: 5, status: 'Completed' })
scanCallback!({ audiobookId: 5, status: 'Completed' })
scanNotificationsStore.registerManualScan('older-scan', 5)
scanNotificationsStore.applyUpdate({
jobId: 'older-scan',
audiobookId: 5,
status: 'Completed',
})
await new Promise((resolve) => setTimeout(resolve, 2))
scanNotificationsStore.registerManualScan('newer-scan', 5)
scanNotificationsStore.applyUpdate({
jobId: 'newer-scan',
audiobookId: 5,
status: 'Completed',
})

resolveNewer({
scanToken: 'new-token',
Expand Down
109 changes: 109 additions & 0 deletions fe/src/__tests__/UnmatchedFilesModal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,113 @@ describe('UnmatchedFilesModal filesystem readiness', () => {
expect(document.body.querySelector('add-library-modal-stub')).toBeNull()
wrapper.unmount()
})

it('shows cached partial-scan warnings even when no unmatched items were found', async () => {
const pinia = createPinia()
setActivePinia(pinia)
useFilesystemReadinessStore().readiness = {
isReady: true,
status: 'ready',
databaseConnected: true,
migrationsCurrent: true,
errorCode: null,
filesystemReady: true,
filesystemStatus: 'Ready',
filesystemPhase: null,
filesystemErrorCode: null,
filesystemErrorMessage: null,
}
vi.mocked(apiService.getSavedUnmatchedFiles).mockResolvedValueOnce({
items: [],
lastScannedAt: new Date().toISOString(),
warnings: ['One path could not be read and was skipped.'],
})
const rootFolder = {
id: 7,
name: 'Library',
path: 'C:\\library',
isDefault: true,
} as unknown as RootFolder

const wrapper = mount(UnmatchedFilesModal, {
props: { isOpen: false, rootFolder },
attachTo: document.body,
global: { plugins: [pinia], stubs: { AddLibraryModal: true } },
})
await wrapper.setProps({ isOpen: true })
await flushPromises()

expect(document.body.textContent).toContain('One path could not be read and was skipped.')
expect(document.body.textContent).toContain('All files are in your library')
wrapper.unmount()
})

it('polls a scan to completion when the SignalR terminal event is missed', async () => {
vi.useFakeTimers()
try {
const pinia = createPinia()
setActivePinia(pinia)
useFilesystemReadinessStore().readiness = {
isReady: true,
status: 'ready',
databaseConnected: true,
migrationsCurrent: true,
errorCode: null,
filesystemReady: true,
filesystemStatus: 'Ready',
filesystemPhase: null,
filesystemErrorCode: null,
filesystemErrorMessage: null,
}
vi.mocked(apiService.getSavedUnmatchedFiles).mockResolvedValueOnce({
items: [],
lastScannedAt: undefined,
warnings: [],
})
vi.mocked(apiService.scanUnmatchedFiles).mockResolvedValueOnce({ jobId: 'scan-job-7' })
vi.mocked(apiService.getUnmatchedResults)
.mockResolvedValueOnce({
jobId: 'scan-job-7',
status: 'Processing',
items: [],
warnings: [],
})
.mockResolvedValue({
jobId: 'scan-job-7',
status: 'Completed',
items: [],
warnings: ['One path could not be read and was skipped.'],
})
const rootFolder = {
id: 7,
name: 'Library',
path: 'C:\\library',
isDefault: true,
} as unknown as RootFolder

const wrapper = mount(UnmatchedFilesModal, {
props: { isOpen: false, rootFolder },
attachTo: document.body,
global: { plugins: [pinia], stubs: { AddLibraryModal: true } },
})
await wrapper.setProps({ isOpen: true })
await flushPromises()
const scan = Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Scan',
)
expect(scan).toBeTruthy()
scan!.click()
await flushPromises()
expect(document.body.textContent).toContain('Scanning')

await vi.advanceTimersByTimeAsync(2500)
await flushPromises()

expect(document.body.textContent).toContain('All files are in your library')
expect(document.body.textContent).toContain('One path could not be read and was skipped.')
wrapper.unmount()
} finally {
vi.useRealTimers()
}
})
})
3 changes: 3 additions & 0 deletions fe/src/__tests__/libraryImport.store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,10 @@ describe('library import store', () => {
getUnmatchedResults.mockImplementation(async (jobId: string) => {
expect(jobId).toBe('own-job')
return {
jobId,
status: 'Completed',
error: null,
warnings: ['One path could not be read and was skipped.'],
items: [
{
fullPath: 'C:\\incoming\\Book A.mp3',
Expand All @@ -373,6 +375,7 @@ describe('library import store', () => {
expect(getUnmatchedResults).toHaveBeenCalledWith('own-job')
expect(Object.keys(store.items)).toEqual(['C:\\incoming\\Book A.mp3'])
expect(store.scanStatus).toBe('done')
expect(store.scanWarnings).toEqual(['One path could not be read and was skipped.'])
})

it('prefers detected title and author for automatic matching before folder fallback', async () => {
Expand Down
89 changes: 74 additions & 15 deletions fe/src/components/feedback/UnmatchedFilesModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@

<!-- Results -->
<div v-else-if="phase === 'results'">
<div v-if="scanWarnings.length" class="scan-warnings" role="status">
<div v-for="warning in scanWarnings" :key="warning" class="scan-warning">
<PhWarning :size="14" />
<span>{{ warning }}</span>
</div>
</div>
<div v-if="items.length === 0" class="empty-state">
<PhCheckCircle class="empty-icon" />
<h4>All files are in your library</h4>
Expand Down Expand Up @@ -231,6 +237,7 @@ type Phase = 'empty' | 'scanning' | 'results' | 'error'
const phase = ref<Phase>('empty')
const items = ref<UnmatchedFileItem[]>([])
const errorMessage = ref('')
const scanWarnings = ref<string[]>([])
const lastScannedAt = ref<string | null>(null)
const addingItem = ref<UnmatchedFileItem | null>(null)
const bulkAdding = ref(false)
Expand Down Expand Up @@ -274,6 +281,16 @@ const fileActionLabel = computed(() =>

let jobId = ''
let offSignalR: (() => void) | null = null
let pollInterval: ReturnType<typeof setInterval> | null = null

function stopScanTracking() {
offSignalR?.()
offSignalR = null
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}

// On open: load cached results — no auto-scan
watch(
Expand All @@ -288,7 +305,8 @@ watch(

try {
const saved = await apiService.getSavedUnmatchedFiles(props.rootFolder.id)
if (saved.items.length > 0) {
scanWarnings.value = saved.warnings ?? []
if (saved.items.length > 0 || saved.lastScannedAt) {
items.value = saved.items
lastScannedAt.value = saved.lastScannedAt ?? null
phase.value = 'results'
Expand All @@ -311,10 +329,26 @@ async function startScan() {
phase.value = 'scanning'
items.value = []
errorMessage.value = ''
scanWarnings.value = []
jobId = ''

stopScanTracking()

function applyCompletedScan(
response: Awaited<ReturnType<typeof apiService.getUnmatchedResults>>,
) {
items.value = response.items
scanWarnings.value = response.warnings ?? []
lastScannedAt.value = new Date().toISOString()
phase.value = 'results'
stopScanTracking()
}

async function completeScan(completedJobId: string) {
applyCompletedScan(await apiService.getUnmatchedResults(completedJobId))
}

// Subscribe to SignalR before triggering the scan
offSignalR?.()
offSignalR = signalRService.onUnmatchedScanComplete(async (payload) => {
if (payload.jobId !== jobId) return
if (payload.error) {
Expand All @@ -323,13 +357,11 @@ async function startScan() {
return
}
try {
const response = await apiService.getUnmatchedResults(payload.jobId)
items.value = response.items
lastScannedAt.value = new Date().toISOString()
phase.value = 'results'
await completeScan(payload.jobId)
} catch (e) {
phase.value = 'error'
errorMessage.value = (e as Error)?.message || 'Failed to fetch results'
stopScanTracking()
}
})

Expand All @@ -339,29 +371,41 @@ async function startScan() {
// Poll once immediately — handles fast scans that complete before SignalR fires
const check = await apiService.getUnmatchedResults(jobId)
if (check.status === 'Completed') {
items.value = check.items
lastScannedAt.value = new Date().toISOString()
phase.value = 'results'
applyCompletedScan(check)
} else if (check.status === 'Failed') {
phase.value = 'error'
errorMessage.value = check.error || 'Scan failed'
stopScanTracking()
} else {
pollInterval = setInterval(async () => {
if (!jobId || phase.value !== 'scanning') return
try {
const poll = await apiService.getUnmatchedResults(jobId)
if (poll.status === 'Completed') {
applyCompletedScan(poll)
} else if (poll.status === 'Failed') {
phase.value = 'error'
errorMessage.value = poll.error || 'Scan failed'
stopScanTracking()
}
} catch {
// Ignore transient polling errors; SignalR or a later poll can still complete the scan.
}
}, 2500)
}
// Otherwise SignalR will deliver the completion event
} catch (e) {
phase.value = 'error'
errorMessage.value = (e as Error)?.message || 'Failed to start scan'
offSignalR?.()
offSignalR = null
stopScanTracking()
}
}

onUnmounted(() => {
offSignalR?.()
stopScanTracking()
})

function close() {
offSignalR?.()
offSignalR = null
stopScanTracking()
emit('close')
}

Expand Down Expand Up @@ -563,6 +607,21 @@ async function addAllWithAsin() {
color: #f03e3e;
}

.scan-warnings {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1rem;
}

.scan-warning {
display: flex;
align-items: flex-start;
gap: 0.35rem;
color: #f59e0b;
font-size: 0.85rem;
}

.error-icon {
width: 40px;
height: 40px;
Expand Down
6 changes: 6 additions & 0 deletions fe/src/stores/libraryImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => {
const rootFolderId = ref<number | null>(null)
const scanStatus = ref<'idle' | 'scanning' | 'done' | 'error'>('idle')
const scanError = ref<string | null>(null)
const scanWarnings = ref<string[]>([])
const lastScannedAt = ref<string | null>(null)
const action = ref<'none' | 'move' | 'hardlink/copy'>('none')
const monitor = ref<'none' | 'all'>('all')
Expand All @@ -157,9 +158,11 @@ export const useLibraryImportStore = defineStore('libraryImport', () => {
async function initFromRootFolder(id: number) {
rootFolderId.value = id
scanStatus.value = 'idle'
scanWarnings.value = []
try {
const saved = await apiService.getSavedUnmatchedFiles(id)
if (saved.lastScannedAt) lastScannedAt.value = saved.lastScannedAt
scanWarnings.value = saved.warnings ?? []
const persisted = _loadPersistedMatches(id)
const newItems: Record<string, LibraryImportItem> = {}
for (const item of saved.items) {
Expand Down Expand Up @@ -196,6 +199,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => {
rootFolderId.value = id
scanStatus.value = 'scanning'
scanError.value = null
scanWarnings.value = []
try {
localStorage.removeItem(_storageKey(id))
} catch {
Expand All @@ -222,6 +226,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => {
try {
const response = await apiService.getUnmatchedResults(completedJobId)
_populateFromItems(response.items)
scanWarnings.value = response.warnings ?? []
_persistMatches()
lastScannedAt.value = new Date().toISOString()
scanStatus.value = 'done'
Expand Down Expand Up @@ -585,6 +590,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => {
rootFolderId,
scanStatus,
scanError,
scanWarnings,
lastScannedAt,
action,
monitor,
Expand Down
Loading
Loading