mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(metadata): refetch Book Dock metadata on demand
Allow ready Book Dock rows to refresh provider matches without depending on the failed-only bulk retry path. Preserve bounded queueing, ownership checks, and stale metadata cleanup. Fixes #1118
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { refetchBookDockMetadata } from './book-dock.api'
|
||||||
|
|
||||||
|
const mockApi = vi.mocked(api)
|
||||||
|
|
||||||
|
describe('book-dock.api', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockApi.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('posts to the dedicated per-file metadata refetch endpoint', async () => {
|
||||||
|
mockApi.mockResolvedValue({ ok: true } as Response)
|
||||||
|
|
||||||
|
await expect(refetchBookDockMetadata(42)).resolves.toBe(true)
|
||||||
|
|
||||||
|
expect(mockApi).toHaveBeenCalledWith('/api/v1/book-dock/files/42/refetch-metadata', {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a rejected refetch response', async () => {
|
||||||
|
mockApi.mockResolvedValue({ ok: false } as Response)
|
||||||
|
|
||||||
|
await expect(refetchBookDockMetadata(42)).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
|
export async function refetchBookDockMetadata(fileId: number): Promise<boolean> {
|
||||||
|
const response = await api(`/api/v1/book-dock/files/${fileId}/refetch-metadata`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
return response.ok
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ const confidence = computed(() => props.file.confidence)
|
|||||||
const hasScore = computed(() => confidence.value !== null && confidence.value !== undefined && confidence.value > 0)
|
const hasScore = computed(() => confidence.value !== null && confidence.value !== undefined && confidence.value > 0)
|
||||||
const weakMatch = computed(() => hasScore.value && (confidence.value as number) < 85)
|
const weakMatch = computed(() => hasScore.value && (confidence.value as number) < 85)
|
||||||
const noMatchYet = computed(() => props.file.status === 'ready' && !hasScore.value)
|
const noMatchYet = computed(() => props.file.status === 'ready' && !hasScore.value)
|
||||||
|
const canRefetch = computed(() => props.file.status === 'ready' || props.file.status === 'error')
|
||||||
|
|
||||||
const edgeClass = computed(() => {
|
const edgeClass = computed(() => {
|
||||||
if (props.file.status === 'error' || props.conflict) return 'border-l-red-500/60'
|
if (props.file.status === 'error' || props.conflict) return 'border-l-red-500/60'
|
||||||
@@ -213,9 +214,10 @@ function onRetry() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="book-dock-row-retry"
|
data-testid="book-dock-row-retry"
|
||||||
class="grid size-7 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
class="grid size-7 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
|
||||||
:title="t('bookDock.layout.row.refetch')"
|
:title="t('bookDock.layout.row.refetch')"
|
||||||
:aria-label="t('bookDock.layout.row.refetch')"
|
:aria-label="t('bookDock.layout.row.refetch')"
|
||||||
|
:disabled="!canRefetch"
|
||||||
@click="onRetry"
|
@click="onRetry"
|
||||||
>
|
>
|
||||||
<RotateCw class="size-3.5" />
|
<RotateCw class="size-3.5" />
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import { shallowMount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { BookDockFile } from '@bookorbit/types'
|
||||||
|
import BookDockFileRow from '../BookDockFileRow.vue'
|
||||||
|
import en from '@/locales/en.json'
|
||||||
|
|
||||||
|
function file(status: BookDockFile['status']): BookDockFile {
|
||||||
|
return {
|
||||||
|
id: 42,
|
||||||
|
fileName: 'book.epub',
|
||||||
|
fileSize: 1024,
|
||||||
|
format: 'epub',
|
||||||
|
status,
|
||||||
|
embeddedMetadata: { title: 'Book' },
|
||||||
|
selectedMetadata: null,
|
||||||
|
fetchedMetadata: null,
|
||||||
|
targetLibraryId: null,
|
||||||
|
targetFolderId: null,
|
||||||
|
confidence: null,
|
||||||
|
fetchedMetadataSources: null,
|
||||||
|
errorMessage: null,
|
||||||
|
metadataEditedAt: null,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountRow(status: BookDockFile['status']) {
|
||||||
|
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } })
|
||||||
|
return shallowMount(BookDockFileRow, {
|
||||||
|
props: {
|
||||||
|
file: file(status),
|
||||||
|
selected: false,
|
||||||
|
expanded: false,
|
||||||
|
focused: false,
|
||||||
|
libraryName: null,
|
||||||
|
targetLabel: 'Unassigned',
|
||||||
|
},
|
||||||
|
global: { plugins: [i18n] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BookDockFileRow metadata refetch action', () => {
|
||||||
|
it.each(['ready', 'error'] as const)('emits retry for a %s file', async (status) => {
|
||||||
|
const wrapper = mountRow(status)
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="book-dock-row-retry"]').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('retry')).toEqual([[expect.objectContaining({ id: 42, status })]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['pending', 'extracting', 'fetching'] as const)('disables refetch while a file is %s', (status) => {
|
||||||
|
const wrapper = mountRow(status)
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-testid="book-dock-row-retry"]').attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -12,6 +12,7 @@ import { useBookDockSummary } from '@/features/book-dock/composables/useBookDock
|
|||||||
import { useBookDockStatistics } from '@/features/book-dock/composables/useBookDockStatistics'
|
import { useBookDockStatistics } from '@/features/book-dock/composables/useBookDockStatistics'
|
||||||
import { useBookDockUpload, SUPPORTED_FORMATS, SUPPORTED_FORMATS_ACCEPT } from '@/features/book-dock/composables/useBookDockUpload'
|
import { useBookDockUpload, SUPPORTED_FORMATS, SUPPORTED_FORMATS_ACCEPT } from '@/features/book-dock/composables/useBookDockUpload'
|
||||||
import { useBookDockConflicts } from '@/features/book-dock/composables/useBookDockConflicts'
|
import { useBookDockConflicts } from '@/features/book-dock/composables/useBookDockConflicts'
|
||||||
|
import { refetchBookDockMetadata } from '@/features/book-dock/api/book-dock.api'
|
||||||
import BookDockActionCluster from '@/features/book-dock/components/BookDockActionCluster.vue'
|
import BookDockActionCluster from '@/features/book-dock/components/BookDockActionCluster.vue'
|
||||||
import BookDockFilterBar from '@/features/book-dock/components/BookDockFilterBar.vue'
|
import BookDockFilterBar from '@/features/book-dock/components/BookDockFilterBar.vue'
|
||||||
import BookDockFileListView from '@/features/book-dock/components/BookDockFileListView.vue'
|
import BookDockFileListView from '@/features/book-dock/components/BookDockFileListView.vue'
|
||||||
@@ -188,12 +189,7 @@ async function handleRowDiscard(file: BookDockFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRowRetry(file: BookDockFile) {
|
async function handleRowRetry(file: BookDockFile) {
|
||||||
await api('/api/v1/book-dock/files/retry-fetch', {
|
if (await refetchBookDockMetadata(file.id)) refresh()
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ fileIds: [file.id] }),
|
|
||||||
})
|
|
||||||
refresh()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDestinationForFile(file: BookDockFile) {
|
function openDestinationForFile(file: BookDockFile) {
|
||||||
|
|||||||
@@ -316,6 +316,63 @@ describe('BookDockIngestService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('refetchMetadata', () => {
|
||||||
|
it.each(['ready', 'error'])('queues a %s row and forces a provider fetch', async (status) => {
|
||||||
|
const { service, repo } = makeService();
|
||||||
|
const dockRow = {
|
||||||
|
id: 4,
|
||||||
|
status,
|
||||||
|
format: 'epub',
|
||||||
|
absolutePath: '/bucket/4.epub',
|
||||||
|
errorMessage: status === 'error' ? 'failed' : null,
|
||||||
|
};
|
||||||
|
repo.findById.mockResolvedValueOnce(dockRow).mockResolvedValueOnce({ ...dockRow, status: 'pending' });
|
||||||
|
repo.update.mockResolvedValue(dockRow);
|
||||||
|
const autoFetchSpy = vi.spyOn(service as any, 'autoFetchMetadataAsync').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(service.refetchMetadata(4)).resolves.toBe(true);
|
||||||
|
await (service as any).metadataQueue.waitForIdle();
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledWith(4, { status: 'pending', errorMessage: null });
|
||||||
|
expect(autoFetchSpy).toHaveBeenCalledWith(4, true);
|
||||||
|
expect((service as any).forcedAutoFetchFileIds.has(4)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores missing, active, and unsupported rows', async () => {
|
||||||
|
const { service, repo } = makeService();
|
||||||
|
repo.findById
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce({ id: 5, status: 'fetching', format: 'epub' })
|
||||||
|
.mockResolvedValueOnce({ id: 6, status: 'ready', format: null, absolutePath: '/bucket/6' });
|
||||||
|
|
||||||
|
await expect(service.refetchMetadata(4)).resolves.toBe(false);
|
||||||
|
await expect(service.refetchMetadata(5)).resolves.toBe(false);
|
||||||
|
await expect(service.refetchMetadata(6)).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the previous state when the work queue rejects the file', async () => {
|
||||||
|
const { service, repo } = makeService();
|
||||||
|
const dockRow = {
|
||||||
|
id: 7,
|
||||||
|
status: 'ready',
|
||||||
|
format: 'epub',
|
||||||
|
absolutePath: '/bucket/7.epub',
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
repo.findById.mockResolvedValue(dockRow);
|
||||||
|
repo.update.mockResolvedValue(dockRow);
|
||||||
|
vi.spyOn(service as any, 'extractMetadataAsync').mockReturnValue(false);
|
||||||
|
|
||||||
|
await expect(service.refetchMetadata(7)).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenNthCalledWith(1, 7, { status: 'pending', errorMessage: null });
|
||||||
|
expect(repo.update).toHaveBeenNthCalledWith(2, 7, { status: 'ready', errorMessage: null });
|
||||||
|
expect((service as any).forcedAutoFetchFileIds.has(7)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('autoFetchMetadataAsync', () => {
|
describe('autoFetchMetadataAsync', () => {
|
||||||
it('returns early when auto-fetch is disabled', async () => {
|
it('returns early when auto-fetch is disabled', async () => {
|
||||||
const { service, appSettings, repo } = makeService();
|
const { service, appSettings, repo } = makeService();
|
||||||
@@ -327,6 +384,34 @@ describe('BookDockIngestService', () => {
|
|||||||
expect(repo.update).not.toHaveBeenCalled();
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forces an explicit refetch when automatic fetching is disabled', async () => {
|
||||||
|
const { service, appSettings, repo, metadataFetchPipeline } = makeService();
|
||||||
|
appSettings.isBookDockAutoFetchEnabled.mockResolvedValue(false);
|
||||||
|
repo.findById.mockResolvedValue({
|
||||||
|
id: 8,
|
||||||
|
fileName: 'dune.epub',
|
||||||
|
status: 'ready',
|
||||||
|
embeddedMetadata: { title: 'Dune' },
|
||||||
|
});
|
||||||
|
(metadataFetchPipeline as any).runWithSources = vi.fn().mockResolvedValue({
|
||||||
|
resolved: { title: 'Dune' },
|
||||||
|
sources: { title: 'google' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await (service as any).autoFetchMetadataAsync(8, true);
|
||||||
|
|
||||||
|
expect(appSettings.isBookDockAutoFetchEnabled).not.toHaveBeenCalled();
|
||||||
|
expect(metadataFetchPipeline.runWithSources).toHaveBeenCalledTimes(1);
|
||||||
|
expect(repo.update).toHaveBeenLastCalledWith(
|
||||||
|
8,
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'ready',
|
||||||
|
fetchedMetadata: { title: 'Dune' },
|
||||||
|
fetchedMetadataSources: { title: 'google' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
fileName: 'Batman #007.cbz',
|
fileName: 'Batman #007.cbz',
|
||||||
@@ -572,6 +657,31 @@ describe('BookDockIngestService', () => {
|
|||||||
expect(repo.update).toHaveBeenNthCalledWith(2, 13, { status: 'ready' });
|
expect(repo.update).toHaveBeenNthCalledWith(2, 13, { status: 'ready' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears stale provider metadata when an explicit refetch finds no result', async () => {
|
||||||
|
const { service, appSettings, repo, metadataFetchPipeline } = makeService();
|
||||||
|
appSettings.isBookDockAutoFetchEnabled.mockResolvedValue(false);
|
||||||
|
repo.findById.mockResolvedValue({
|
||||||
|
id: 15,
|
||||||
|
fileName: 'known-title.epub',
|
||||||
|
status: 'ready',
|
||||||
|
embeddedMetadata: { title: 'Known Title' },
|
||||||
|
fetchedMetadata: { title: 'Stale Title' },
|
||||||
|
fetchedMetadataSources: { title: 'google' },
|
||||||
|
confidence: 90,
|
||||||
|
});
|
||||||
|
(metadataFetchPipeline as any).runWithSources = vi.fn().mockResolvedValue({ resolved: {}, sources: {} });
|
||||||
|
|
||||||
|
await (service as any).autoFetchMetadataAsync(15, true);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenNthCalledWith(1, 15, { status: 'fetching' });
|
||||||
|
expect(repo.update).toHaveBeenNthCalledWith(2, 15, {
|
||||||
|
status: 'ready',
|
||||||
|
fetchedMetadata: null,
|
||||||
|
confidence: null,
|
||||||
|
fetchedMetadataSources: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('emits a summary refresh before provider fetching starts', async () => {
|
it('emits a summary refresh before provider fetching starts', async () => {
|
||||||
const { service, appSettings, repo, metadataFetchPipeline } = makeService();
|
const { service, appSettings, repo, metadataFetchPipeline } = makeService();
|
||||||
appSettings.isBookDockAutoFetchEnabled.mockResolvedValue(true);
|
appSettings.isBookDockAutoFetchEnabled.mockResolvedValue(true);
|
||||||
@@ -603,7 +713,7 @@ describe('BookDockIngestService', () => {
|
|||||||
await (service as any).metadataQueue.waitForIdle();
|
await (service as any).metadataQueue.waitForIdle();
|
||||||
|
|
||||||
expect(metadataService.extractAndSave).toHaveBeenCalledWith(12, '/bucket/12.epub', 'epub', '/books/book-dock/covers');
|
expect(metadataService.extractAndSave).toHaveBeenCalledWith(12, '/bucket/12.epub', 'epub', '/books/book-dock/covers');
|
||||||
expect(autoFetchSpy).toHaveBeenCalledWith(12);
|
expect(autoFetchSpy).toHaveBeenCalledWith(12, false);
|
||||||
expect(repo.update).toHaveBeenCalledWith(12, { status: 'extracting' });
|
expect(repo.update).toHaveBeenCalledWith(12, { status: 'extracting' });
|
||||||
expect(emitSummarySpy).toHaveBeenCalledTimes(2);
|
expect(emitSummarySpy).toHaveBeenCalledTimes(2);
|
||||||
expect(events.emit).toHaveBeenCalledWith('book-dock.file.ingested', 12);
|
expect(events.emit).toHaveBeenCalledWith('book-dock.file.ingested', 12);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe
|
|||||||
private readonly logger = new Logger(BookDockIngestService.name);
|
private readonly logger = new Logger(BookDockIngestService.name);
|
||||||
private bookDockPath: string;
|
private bookDockPath: string;
|
||||||
private readonly metadataQueue: BookDockWorkQueue;
|
private readonly metadataQueue: BookDockWorkQueue;
|
||||||
|
private readonly forcedAutoFetchFileIds = new Set<number>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
@@ -111,6 +112,28 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe
|
|||||||
this.extractMetadataAsync(fileId, row.format, metadataQueuePriority(row));
|
this.extractMetadataAsync(fileId, row.format, metadataQueuePriority(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refetchMetadata(fileId: number): Promise<boolean> {
|
||||||
|
const row = await this.repo.findById(fileId);
|
||||||
|
if (!row || (row.status !== 'ready' && row.status !== 'error')) return false;
|
||||||
|
|
||||||
|
const format = resolveSupportedFormat(row);
|
||||||
|
if (!format) return false;
|
||||||
|
|
||||||
|
this.forcedAutoFetchFileIds.add(fileId);
|
||||||
|
const updated = await this.repo.update(fileId, { status: 'pending', errorMessage: null });
|
||||||
|
if (!updated) {
|
||||||
|
this.forcedAutoFetchFileIds.delete(fileId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queued = this.extractMetadataAsync(fileId, format, metadataQueuePriority(row));
|
||||||
|
if (!queued) {
|
||||||
|
this.forcedAutoFetchFileIds.delete(fileId);
|
||||||
|
await this.repo.update(fileId, { status: row.status, errorMessage: row.errorMessage });
|
||||||
|
}
|
||||||
|
return queued;
|
||||||
|
}
|
||||||
|
|
||||||
async ingestFromWatchedFolder(absolutePath: string): Promise<number | null> {
|
async ingestFromWatchedFolder(absolutePath: string): Promise<number | null> {
|
||||||
const existing = await this.repo.findByAbsolutePath(absolutePath);
|
const existing = await this.repo.findByAbsolutePath(absolutePath);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -142,10 +165,10 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe
|
|||||||
return row.id;
|
return row.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractMetadataAsync(fileId: number, format: string, priority?: BookDockWorkPriority): void {
|
private extractMetadataAsync(fileId: number, format: string, priority?: BookDockWorkPriority): boolean {
|
||||||
if (!isSupportedFormat(format)) return;
|
if (!isSupportedFormat(format)) return false;
|
||||||
if (this.processingState.getCachedPaused()) this.metadataQueue.pause();
|
if (this.processingState.getCachedPaused()) this.metadataQueue.pause();
|
||||||
this.metadataQueue.enqueue(fileId, priority);
|
return this.metadataQueue.enqueue(fileId, priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
pauseProcessing(): void {
|
pauseProcessing(): void {
|
||||||
@@ -195,19 +218,24 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = await this.repo.findById(fileId);
|
const forceAutoFetch = this.forcedAutoFetchFileIds.has(fileId);
|
||||||
if (!row || !PROCESSABLE_METADATA_STATUSES.has(row.status)) return;
|
try {
|
||||||
|
const row = await this.repo.findById(fileId);
|
||||||
|
if (!row || !PROCESSABLE_METADATA_STATUSES.has(row.status)) return;
|
||||||
|
|
||||||
const format = resolveSupportedFormat(row);
|
const format = resolveSupportedFormat(row);
|
||||||
if (!format) return;
|
if (!format) return;
|
||||||
|
|
||||||
const coversDir = join(this.bookDockPath, 'covers');
|
const coversDir = join(this.bookDockPath, 'covers');
|
||||||
await this.repo.update(fileId, { status: 'extracting' });
|
await this.repo.update(fileId, { status: 'extracting' });
|
||||||
this.emitChange();
|
this.emitChange();
|
||||||
await this.metadataService.extractAndSave(fileId, row.absolutePath, format, coversDir);
|
await this.metadataService.extractAndSave(fileId, row.absolutePath, format, coversDir);
|
||||||
await this.autoFetchMetadataAsync(fileId);
|
await this.autoFetchMetadataAsync(fileId, forceAutoFetch);
|
||||||
this.emitChange();
|
this.emitChange();
|
||||||
this.events.emit(BOOK_DOCK_FILE_INGESTED, fileId);
|
this.events.emit(BOOK_DOCK_FILE_INGESTED, fileId);
|
||||||
|
} finally {
|
||||||
|
this.forcedAutoFetchFileIds.delete(fileId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private logMetadataQueueFailure(fileId: number, err: unknown): void {
|
private logMetadataQueueFailure(fileId: number, err: unknown): void {
|
||||||
@@ -218,9 +246,11 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async autoFetchMetadataAsync(fileId: number): Promise<void> {
|
private async autoFetchMetadataAsync(fileId: number, force = false): Promise<void> {
|
||||||
const enabled = await this.appSettings.isBookDockAutoFetchEnabled();
|
if (!force) {
|
||||||
if (!enabled) return;
|
const enabled = await this.appSettings.isBookDockAutoFetchEnabled();
|
||||||
|
if (!enabled) return;
|
||||||
|
}
|
||||||
|
|
||||||
const row = await this.repo.findById(fileId);
|
const row = await this.repo.findById(fileId);
|
||||||
if (!row || row.status === 'error') return;
|
if (!row || row.status === 'error') return;
|
||||||
@@ -254,7 +284,14 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe
|
|||||||
confidence: computeConfidence(row.embeddedMetadata ?? {}, fetched),
|
confidence: computeConfidence(row.embeddedMetadata ?? {}, fetched),
|
||||||
fetchedMetadataSources,
|
fetchedMetadataSources,
|
||||||
}
|
}
|
||||||
: { status: 'ready' as const };
|
: force
|
||||||
|
? {
|
||||||
|
status: 'ready' as const,
|
||||||
|
fetchedMetadata: null,
|
||||||
|
confidence: null,
|
||||||
|
fetchedMetadataSources: null,
|
||||||
|
}
|
||||||
|
: { status: 'ready' as const };
|
||||||
await this.repo.update(fileId, updates);
|
await this.repo.update(fileId, updates);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Auto-fetch metadata failed for Book Dock file ${fileId}: ${err instanceof Error ? err.message : String(err)}`);
|
this.logger.warn(`Auto-fetch metadata failed for Book Dock file ${fileId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function makeController() {
|
|||||||
bulkDiscard: vi.fn(),
|
bulkDiscard: vi.fn(),
|
||||||
bulkApplyFetched: vi.fn(),
|
bulkApplyFetched: vi.fn(),
|
||||||
bulkRetryFetch: vi.fn(),
|
bulkRetryFetch: vi.fn(),
|
||||||
|
refetchMetadata: vi.fn(),
|
||||||
bulkSetTarget: vi.fn(),
|
bulkSetTarget: vi.fn(),
|
||||||
selectionSummary: vi.fn(),
|
selectionSummary: vi.fn(),
|
||||||
bulkEdit: vi.fn(),
|
bulkEdit: vi.fn(),
|
||||||
@@ -145,6 +146,7 @@ describe('BookDockController', () => {
|
|||||||
await controller.bulkDiscard(MOCK_USER, { fileIds: [1], selectAll: false, excludedIds: [2], status: 'error', search: 'x' });
|
await controller.bulkDiscard(MOCK_USER, { fileIds: [1], selectAll: false, excludedIds: [2], status: 'error', search: 'x' });
|
||||||
await controller.applyFetched(MOCK_USER, { fileIds: [1], selectAll: true, excludedIds: [2], status: 'ready', search: 'x' });
|
await controller.applyFetched(MOCK_USER, { fileIds: [1], selectAll: true, excludedIds: [2], status: 'ready', search: 'x' });
|
||||||
await controller.retryFetch(MOCK_USER, { fileIds: [3], selectAll: false, excludedIds: [4], status: 'error', search: 'y' });
|
await controller.retryFetch(MOCK_USER, { fileIds: [3], selectAll: false, excludedIds: [4], status: 'error', search: 'y' });
|
||||||
|
await controller.refetchMetadata(MOCK_USER, 11);
|
||||||
await controller.setTarget(MOCK_USER, {
|
await controller.setTarget(MOCK_USER, {
|
||||||
fileIds: [5],
|
fileIds: [5],
|
||||||
selectAll: false,
|
selectAll: false,
|
||||||
@@ -187,6 +189,7 @@ describe('BookDockController', () => {
|
|||||||
await controller.resume();
|
await controller.resume();
|
||||||
|
|
||||||
expect(service.bulkSetTarget).toHaveBeenCalledWith([5], false, [6], null, null, undefined, undefined, MOCK_USER.id, false, undefined);
|
expect(service.bulkSetTarget).toHaveBeenCalledWith([5], false, [6], null, null, undefined, undefined, MOCK_USER.id, false, undefined);
|
||||||
|
expect(service.refetchMetadata).toHaveBeenCalledWith(11, MOCK_USER.id, false);
|
||||||
expect(finalizeService.previewNames).toHaveBeenCalledWith([10], false, [], 2, MOCK_USER.id, false, undefined, undefined, undefined);
|
expect(finalizeService.previewNames).toHaveBeenCalledWith([10], false, [], 2, MOCK_USER.id, false, undefined, undefined, undefined);
|
||||||
expect(finalizeService.previewFinalize).toHaveBeenCalledWith(1, false, false, [10], false, [], 2, 3, [], undefined, undefined, undefined);
|
expect(finalizeService.previewFinalize).toHaveBeenCalledWith(1, false, false, [10], false, [], 2, 3, [], undefined, undefined, undefined);
|
||||||
expect(finalizeService.discardDuplicateCandidates).toHaveBeenCalledWith(
|
expect(finalizeService.discardDuplicateCandidates).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -203,6 +203,12 @@ export class BookDockController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('files/:id/refetch-metadata')
|
||||||
|
@HttpCode(HttpStatus.ACCEPTED)
|
||||||
|
refetchMetadata(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.service.refetchMetadata(id, user.id, this.canManageAll(user));
|
||||||
|
}
|
||||||
|
|
||||||
@Post('files/set-target')
|
@Post('files/set-target')
|
||||||
setTarget(@CurrentUser() user: RequestUser, @Body() dto: BulkSetTargetDto) {
|
setTarget(@CurrentUser() user: RequestUser, @Body() dto: BulkSetTargetDto) {
|
||||||
return this.service.bulkSetTarget(
|
return this.service.bulkSetTarget(
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ function makeService() {
|
|||||||
};
|
};
|
||||||
const ingestService = {
|
const ingestService = {
|
||||||
retryFetch: vi.fn(),
|
retryFetch: vi.fn(),
|
||||||
|
refetchMetadata: vi.fn(),
|
||||||
pauseProcessing: vi.fn(),
|
pauseProcessing: vi.fn(),
|
||||||
resumeProcessing: vi.fn().mockResolvedValue(undefined),
|
resumeProcessing: vi.fn().mockResolvedValue(undefined),
|
||||||
requeueProcessableFiles: vi.fn().mockResolvedValue(0),
|
requeueProcessableFiles: vi.fn().mockResolvedValue(0),
|
||||||
@@ -260,6 +261,27 @@ describe('BookDockService', () => {
|
|||||||
expect(ingestService.retryFetch).toHaveBeenCalledWith(3);
|
expect(ingestService.retryFetch).toHaveBeenCalledWith(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refetchMetadata enforces ownership and queues an eligible file', async () => {
|
||||||
|
const { service, repo, ingestService } = makeService();
|
||||||
|
repo.findById.mockResolvedValue(row({ id: 7, uploadedBy: 4 }));
|
||||||
|
ingestService.refetchMetadata.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await expect(service.refetchMetadata(7, 4, false)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(ingestService.refetchMetadata).toHaveBeenCalledWith(7);
|
||||||
|
|
||||||
|
await expect(service.refetchMetadata(7, 5, false)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(ingestService.refetchMetadata).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refetchMetadata rejects a file that cannot be queued', async () => {
|
||||||
|
const { service, repo, ingestService } = makeService();
|
||||||
|
repo.findById.mockResolvedValue(row({ id: 7, uploadedBy: 4 }));
|
||||||
|
ingestService.refetchMetadata.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(service.refetchMetadata(7, 4, false)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
it('bulkSetTarget enforces complete destination tuple and returns update counts', async () => {
|
it('bulkSetTarget enforces complete destination tuple and returns update counts', async () => {
|
||||||
const { service, repo } = makeService();
|
const { service, repo } = makeService();
|
||||||
await expect(service.bulkSetTarget([1], false, [], 10, undefined)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(service.bulkSetTarget([1], false, [], 10, undefined)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|||||||
@@ -249,6 +249,12 @@ export class BookDockService {
|
|||||||
return { total, queued };
|
return { total, queued };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refetchMetadata(id: number, userId: number, canManageAll: boolean): Promise<void> {
|
||||||
|
await this.findFileForUser(id, userId, canManageAll);
|
||||||
|
const queued = await this.ingestService.refetchMetadata(id);
|
||||||
|
if (!queued) throw new BadRequestException('Metadata can only be re-fetched for a ready or failed Book Dock file');
|
||||||
|
}
|
||||||
|
|
||||||
async bulkSetTarget(
|
async bulkSetTarget(
|
||||||
fileIds: number[],
|
fileIds: number[],
|
||||||
selectAll?: boolean,
|
selectAll?: boolean,
|
||||||
|
|||||||
Reference in New Issue
Block a user