feat(scanner): detect moved books via inode matching and propagate

This commit is contained in:
neon
2026-01-30 05:05:47 -06:00
parent 325d0f6f0f
commit 32410b340c
15 changed files with 318 additions and 53 deletions
@@ -52,7 +52,7 @@ function openFile(file: BookFileRef) {
<!-- Cover --> <!-- Cover -->
<div <div
class="relative w-full rounded-sm overflow-hidden shadow-md transition-all duration-150" class="relative w-full rounded-sm overflow-hidden shadow-md transition-all duration-150"
:class="isMissing ? 'grayscale opacity-60' : 'group-hover:shadow-xl group-hover:scale-[1.02]'" :class="isMissing ? 'ring-2 ring-amber-500' : 'group-hover:shadow-xl group-hover:scale-[1.02]'"
style="aspect-ratio: 2/3" style="aspect-ratio: 2/3"
:style="coverLoaded ? {} : coverStyle" :style="coverLoaded ? {} : coverStyle"
> >
@@ -60,7 +60,7 @@ function openFile(file: BookFileRef) {
v-if="!coverFailed" v-if="!coverFailed"
:src="coverSrc" :src="coverSrc"
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-200" class="absolute inset-0 w-full h-full object-cover transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'" :class="coverLoaded ? (isMissing ? 'opacity-100 brightness-50' : 'opacity-100') : 'opacity-0'"
loading="lazy" loading="lazy"
:alt="book.title ?? ''" :alt="book.title ?? ''"
@load="coverLoaded = true" @load="coverLoaded = true"
@@ -105,7 +105,7 @@ function openFile(file: BookFileRef) {
<!-- Center: primary action buttons --> <!-- Center: primary action buttons -->
<div class="flex-1 flex flex-col items-center justify-center gap-[18cqi]"> <div class="flex-1 flex flex-col items-center justify-center gap-[18cqi]">
<button <button
v-if="primaryFile" v-if="primaryFile && !isMissing"
class="p-[7cqi] rounded-full bg-primary/50 hover:bg-primary transition-colors text-white" class="p-[7cqi] rounded-full bg-primary/50 hover:bg-primary transition-colors text-white"
title="Read" title="Read"
@click="openFile(primaryFile)" @click="openFile(primaryFile)"
@@ -43,11 +43,11 @@ const presentBook: BookCard = {
} }
describe('BookCoverCard — missing state', () => { describe('BookCoverCard — missing state', () => {
it('applies grayscale and opacity-60 to the cover container', () => { it('applies amber ring to the cover container when missing', () => {
const wrapper = mount(BookCoverCard, { props: { book: missingBook }, global: globalStubs }) const wrapper = mount(BookCoverCard, { props: { book: missingBook }, global: globalStubs })
const coverDiv = wrapper.find('[style*="aspect-ratio"]') const coverDiv = wrapper.find('[style*="aspect-ratio"]')
expect(coverDiv.classes()).toContain('grayscale') expect(coverDiv.classes()).toContain('ring-2')
expect(coverDiv.classes()).toContain('opacity-60') expect(coverDiv.classes()).toContain('ring-amber-500')
}) })
it('does not apply hover-scale to the cover container', () => { it('does not apply hover-scale to the cover container', () => {
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { BookOpen, Download, FolderPlus, Trash2, TriangleAlert, X } from 'lucide-vue-next' import { BookOpen, Download, Folder, FolderPlus, Trash2, TriangleAlert, X } from 'lucide-vue-next'
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui' import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
import { bookCoverStyle } from '@/features/book/lib/book-cover' import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions' import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
@@ -142,6 +142,14 @@ function downloadFile() {
</span> </span>
</div> </div>
<!-- Location -->
<div class="mt-5 pt-5 border-t border-border">
<div class="flex items-center gap-2 min-w-0">
<Folder class="size-3.5 text-muted-foreground shrink-0" />
<p class="text-xs text-muted-foreground font-mono truncate min-w-0" :title="book.folderPath">{{ book.folderPath }}</p>
</div>
</div>
<!-- Metadata grid --> <!-- Metadata grid -->
<dl <dl
v-if="book.publisher || book.publishedYear || book.language || book.pageCount || book.isbn13 || book.isbn10" v-if="book.publisher || book.publishedYear || book.language || book.pageCount || book.isbn13 || book.isbn10"
@@ -50,6 +50,7 @@ function downloadFile(file: BookDetailFile) {
<!-- File info --> <!-- File info -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm truncate">{{ file.filename ?? '-' }}</p> <p class="text-sm truncate">{{ file.filename ?? '-' }}</p>
<p class="text-xs text-muted-foreground font-mono truncate mt-0.5" :title="file.absolutePath">{{ file.absolutePath }}</p>
<p class="text-xs text-muted-foreground mt-0.5"> <p class="text-xs text-muted-foreground mt-0.5">
{{ formatBytes(file.sizeBytes) }} {{ formatBytes(file.sizeBytes) }}
<span class="mx-1">·</span> <span class="mx-1">·</span>
@@ -1,24 +1,34 @@
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import type { BookMissingEvent, BookRestoredEvent } from '@projectx/types' import type { BookMissingEvent, BookMovedEvent, BookRestoredEvent } from '@projectx/types'
import { getSocket } from '@/features/scanner/composables/useScanProgress' import { getSocket } from '@/features/scanner/composables/useScanProgress'
type BookMissingCallback = (bookIds: number[]) => void type BookIdsCallback = (bookIds: number[]) => void
type BookRestoredCallback = (bookIds: number[]) => void
const missingCallbacks = new Set<BookMissingCallback>() const missingCallbacks = new Set<BookIdsCallback>()
const restoredCallbacks = new Set<BookRestoredCallback>() const restoredCallbacks = new Set<BookIdsCallback>()
const movedCallbacks = new Set<BookIdsCallback>()
let pendingMissingCount = 0 let pendingMissingCount = 0
let toastTimer: ReturnType<typeof setTimeout> | null = null let pendingMovedCount = 0
let missingToastTimer: ReturnType<typeof setTimeout> | null = null
let movedToastTimer: ReturnType<typeof setTimeout> | null = null
function flushMissingToast() { function flushMissingToast() {
if (pendingMissingCount === 0) return if (pendingMissingCount === 0) return
const count = pendingMissingCount const count = pendingMissingCount
pendingMissingCount = 0 pendingMissingCount = 0
toastTimer = null missingToastTimer = null
toast.warning(count === 1 ? '1 book is no longer available on disk.' : `${count} books are no longer available on disk.`) toast.warning(count === 1 ? '1 book is no longer available on disk.' : `${count} books are no longer available on disk.`)
} }
function flushMovedToast() {
if (pendingMovedCount === 0) return
const count = pendingMovedCount
pendingMovedCount = 0
movedToastTimer = null
toast.info(count === 1 ? '1 book was moved to a new location.' : `${count} books were moved to new locations.`)
}
let initialized = false let initialized = false
function ensureInitialized() { function ensureInitialized() {
@@ -30,27 +40,39 @@ function ensureInitialized() {
socket.on('book:missing', (event: BookMissingEvent) => { socket.on('book:missing', (event: BookMissingEvent) => {
for (const cb of missingCallbacks) cb(event.bookIds) for (const cb of missingCallbacks) cb(event.bookIds)
pendingMissingCount += event.bookIds.length pendingMissingCount += event.bookIds.length
clearTimeout(toastTimer ?? undefined) clearTimeout(missingToastTimer ?? undefined)
toastTimer = setTimeout(flushMissingToast, 1000) missingToastTimer = setTimeout(flushMissingToast, 1000)
}) })
socket.on('book:restored', (event: BookRestoredEvent) => { socket.on('book:restored', (event: BookRestoredEvent) => {
for (const cb of restoredCallbacks) cb(event.bookIds) for (const cb of restoredCallbacks) cb(event.bookIds)
}) })
socket.on('book:moved', (event: BookMovedEvent) => {
for (const cb of movedCallbacks) cb(event.bookIds)
pendingMovedCount += event.bookIds.length
clearTimeout(movedToastTimer ?? undefined)
movedToastTimer = setTimeout(flushMovedToast, 1000)
})
} }
export function useBookEvents() { export function useBookEvents() {
ensureInitialized() ensureInitialized()
function onBookMissing(cb: BookMissingCallback): () => void { function onBookMissing(cb: BookIdsCallback): () => void {
missingCallbacks.add(cb) missingCallbacks.add(cb)
return () => missingCallbacks.delete(cb) return () => missingCallbacks.delete(cb)
} }
function onBookRestored(cb: BookRestoredCallback): () => void { function onBookRestored(cb: BookIdsCallback): () => void {
restoredCallbacks.add(cb) restoredCallbacks.add(cb)
return () => restoredCallbacks.delete(cb) return () => restoredCallbacks.delete(cb)
} }
return { onBookMissing, onBookRestored } function onBookMoved(cb: BookIdsCallback): () => void {
movedCallbacks.add(cb)
return () => movedCallbacks.delete(cb)
}
return { onBookMissing, onBookRestored, onBookMoved }
} }
+6 -3
View File
@@ -25,7 +25,7 @@ const { libraries, fetchLibraries } = useLibraries()
const { subscribeLibrary } = useScanProgress() const { subscribeLibrary } = useScanProgress()
watch(() => detail.value?.libraryId, (id) => { if (id !== undefined) subscribeLibrary(id) }) watch(() => detail.value?.libraryId, (id) => { if (id !== undefined) subscribeLibrary(id) })
const { onBookMissing, onBookRestored } = useBookEvents() const { onBookMissing, onBookRestored, onBookMoved } = useBookEvents()
onBookMissing((bookIds) => { onBookMissing((bookIds) => {
if (detail.value && bookIds.includes(detail.value.id)) { if (detail.value && bookIds.includes(detail.value.id)) {
detail.value = { ...detail.value, status: 'missing' } detail.value = { ...detail.value, status: 'missing' }
@@ -34,6 +34,9 @@ onBookMissing((bookIds) => {
onBookRestored((bookIds) => { onBookRestored((bookIds) => {
if (detail.value && bookIds.includes(detail.value.id)) fetch(detail.value.id) if (detail.value && bookIds.includes(detail.value.id)) fetch(detail.value.id)
}) })
onBookMoved((bookIds) => {
if (detail.value && bookIds.includes(detail.value.id)) fetch(detail.value.id)
})
watch(bookId, (id) => fetch(id), { immediate: true }) watch(bookId, (id) => fetch(id), { immediate: true })
fetchLibraries() fetchLibraries()
@@ -60,14 +63,14 @@ watch(activeTab, (tab) => visitedTabs.add(tab), { immediate: true })
<template> <template>
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<SidebarInset class="flex flex-col min-h-screen"> <SidebarInset class="flex flex-col min-h-screen overflow-x-hidden">
<AppHeader /> <AppHeader />
<div class="flex items-center border-b shrink-0 h-11"> <div class="flex items-center border-b shrink-0 h-11">
<BookDetailHeader :library-name="libraryName" :loading="loading" @back="goBack" /> <BookDetailHeader :library-name="libraryName" :loading="loading" @back="goBack" />
<BookDetailTabs :active-tab="activeTab" @update:active-tab="setTab" /> <BookDetailTabs :active-tab="activeTab" @update:active-tab="setTab" />
</div> </div>
<main class="flex-1 overflow-y-auto px-6 py-6"> <main class="flex-1 overflow-y-auto overflow-x-hidden px-6 py-6">
<template v-if="detail"> <template v-if="detail">
<DetailsTab v-if="visitedTabs.has('details')" v-show="activeTab === 'details'" :book="detail" /> <DetailsTab v-if="visitedTabs.has('details')" v-show="activeTab === 'details'" :book="detail" />
<FilesTab v-if="visitedTabs.has('files')" v-show="activeTab === 'files'" :book="detail" /> <FilesTab v-if="visitedTabs.has('files')" v-show="activeTab === 'files'" :book="detail" />
+7 -1
View File
@@ -34,7 +34,7 @@ const { items: books, total, loading, error, filter, sort, hasMore, load, clear
const { subscribeLibrary } = useScanProgress() const { subscribeLibrary } = useScanProgress()
watch(libraryId, (id) => { if (id !== null) subscribeLibrary(id) }, { immediate: true }) watch(libraryId, (id) => { if (id !== null) subscribeLibrary(id) }, { immediate: true })
const { onBookMissing, onBookRestored } = useBookEvents() const { onBookMissing, onBookRestored, onBookMoved } = useBookEvents()
onBookMissing((bookIds) => { onBookMissing((bookIds) => {
const missing = new Set(bookIds) const missing = new Set(bookIds)
for (const book of books.value) { for (const book of books.value) {
@@ -47,6 +47,12 @@ onBookRestored((bookIds) => {
if (restored.has(book.id)) book.status = 'present' if (restored.has(book.id)) book.status = 'present'
} }
}) })
onBookMoved((bookIds) => {
const moved = new Set(bookIds)
for (const book of books.value) {
if (moved.has(book.id)) book.status = 'present'
}
})
const filterOpen = ref(false) const filterOpen = ref(false)
+2
View File
@@ -19,6 +19,7 @@ export type BookDetailFile = {
format: string | null; format: string | null;
role: string; role: string;
sizeBytes: number | null; sizeBytes: number | null;
absolutePath: string;
createdAt: string; createdAt: string;
filename: string | null; filename: string | null;
}; };
@@ -27,6 +28,7 @@ export type BookDetail = {
id: number; id: number;
libraryId: number; libraryId: number;
status: string; status: string;
folderPath: string;
title: string | null; title: string | null;
subtitle: string | null; subtitle: string | null;
description: string | null; description: string | null;
+5
View File
@@ -31,3 +31,8 @@ export interface BookRestoredEvent {
libraryId: number; libraryId: number;
bookIds: number[]; bookIds: number[];
} }
export interface BookMovedEvent {
libraryId: number;
bookIds: number[];
}
+1 -1
View File
@@ -5,7 +5,7 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1769672547955, "when": 1769817612746,
"tag": "0000_baseline", "tag": "0000_baseline",
"breakpoints": true "breakpoints": true
} }
@@ -3,11 +3,13 @@ import { ScannerRepository } from './scanner.repository';
jest.mock('fs/promises', () => ({ jest.mock('fs/promises', () => ({
stat: jest.fn(), stat: jest.fn(),
readdir: jest.fn(),
})); }));
import { stat } from 'fs/promises'; import { stat, readdir } from 'fs/promises';
const mockStat = stat as jest.MockedFunction<typeof stat>; const mockStat = stat as jest.MockedFunction<typeof stat>;
const mockReaddir = readdir as jest.MockedFunction<typeof readdir>;
const mockRepo: jest.Mocked< const mockRepo: jest.Mocked<
Pick< Pick<
@@ -22,6 +24,8 @@ const mockRepo: jest.Mocked<
| 'createBookFile' | 'createBookFile'
| 'updateBookFile' | 'updateBookFile'
| 'markBooksAsPresent' | 'markBooksAsPresent'
| 'findBookFileWithContextByIno'
| 'updateBookFolderPath'
> >
> = { > = {
findBookFileByAbsolutePath: jest.fn(), findBookFileByAbsolutePath: jest.fn(),
@@ -34,6 +38,8 @@ const mockRepo: jest.Mocked<
createBookFile: jest.fn(), createBookFile: jest.fn(),
updateBookFile: jest.fn(), updateBookFile: jest.fn(),
markBooksAsPresent: jest.fn(), markBooksAsPresent: jest.fn(),
findBookFileWithContextByIno: jest.fn(),
updateBookFolderPath: jest.fn(),
}; };
function makeService() { function makeService() {
@@ -56,6 +62,9 @@ beforeEach(() => {
mockRepo.markBooksAsPresent.mockResolvedValue(undefined); mockRepo.markBooksAsPresent.mockResolvedValue(undefined);
mockRepo.createBookFile.mockResolvedValue({} as any); mockRepo.createBookFile.mockResolvedValue({} as any);
mockRepo.updateBookFile.mockResolvedValue({} as any); mockRepo.updateBookFile.mockResolvedValue({} as any);
mockRepo.findBookFileWithContextByIno.mockResolvedValue(null);
mockRepo.updateBookFolderPath.mockResolvedValue(undefined);
mockReaddir.mockResolvedValue([]);
}); });
// ── handleUnlink ────────────────────────────────────────────────────────────── // ── handleUnlink ──────────────────────────────────────────────────────────────
@@ -252,6 +261,132 @@ describe('handleCreate — directory (folder restoration)', () => {
}); });
}); });
// ── handleCreate — move detection (inode-based) ─────────────────────────────
describe('handleCreate — move detection', () => {
it('detects a moved file via inode match and updates paths', async () => {
const fileStat = makeFileStat({ ino: 4000, size: 70000 });
mockStat.mockResolvedValue(fileStat);
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
mockRepo.findMissingBookByFolderPath.mockResolvedValue(null);
mockRepo.findBookFileWithContextByIno.mockResolvedValue({
file: { id: 50, bookId: 30, absolutePath: '/books/OldAuthor/book.epub' },
libraryId: 2,
folderPath: '/books/OldAuthor',
libraryFolderPath: '/books',
bookStatus: 'missing',
} as any);
const result = await makeService().handleCreate('/books/NewAuthor/book.epub');
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(
50,
expect.objectContaining({ absolutePath: '/books/NewAuthor/book.epub', relPath: 'NewAuthor/book.epub' }),
);
expect(mockRepo.updateBookFolderPath).toHaveBeenCalledWith(30, '/books/NewAuthor');
expect(mockRepo.markBooksAsPresent).toHaveBeenCalledWith([30]);
expect(result).toEqual({ type: 'book-moved', libraryId: 2, bookIds: [30] });
});
it('skips move detection when inode matches same path (not a move)', async () => {
const fileStat = makeFileStat({ ino: 4000 });
mockStat.mockResolvedValue(fileStat);
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
mockRepo.findMissingBookByFolderPath.mockResolvedValue(null);
mockRepo.findBookFileWithContextByIno.mockResolvedValue({
file: { id: 50, bookId: 30, absolutePath: '/books/Author/book.epub' },
libraryId: 2,
folderPath: '/books/Author',
libraryFolderPath: '/books',
bookStatus: 'present',
} as any);
const result = await makeService().handleCreate('/books/Author/book.epub');
expect(result).toEqual({ type: 'noop' });
expect(mockRepo.updateBookFile).not.toHaveBeenCalled();
});
it('does not update folderPath when folder did not change', async () => {
const fileStat = makeFileStat({ ino: 4000, size: 70000 });
mockStat.mockResolvedValue(fileStat);
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
mockRepo.findMissingBookByFolderPath.mockResolvedValue(null);
mockRepo.findBookFileWithContextByIno.mockResolvedValue({
file: { id: 50, bookId: 30, absolutePath: '/books/Author/old.epub' },
libraryId: 2,
folderPath: '/books/Author',
libraryFolderPath: '/books',
bookStatus: 'missing',
} as any);
const result = await makeService().handleCreate('/books/Author/renamed.epub');
expect(mockRepo.updateBookFolderPath).not.toHaveBeenCalled();
expect(result).toEqual({ type: 'book-moved', libraryId: 2, bookIds: [30] });
});
it('handles root-level file rename (folderPath equals old absolutePath)', async () => {
const fileStat = makeFileStat({ ino: 5000 });
mockStat.mockResolvedValue(fileStat);
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
mockRepo.findMissingBookByFolderPath.mockResolvedValue(null);
mockRepo.findBookFileWithContextByIno.mockResolvedValue({
file: { id: 60, bookId: 40, absolutePath: '/books/old.epub' },
libraryId: 3,
folderPath: '/books/old.epub',
libraryFolderPath: '/books',
bookStatus: 'missing',
} as any);
const result = await makeService().handleCreate('/books/renamed.epub');
expect(mockRepo.updateBookFolderPath).toHaveBeenCalledWith(40, '/books/renamed.epub');
expect(result).toEqual({ type: 'book-moved', libraryId: 3, bookIds: [40] });
});
});
// ── handleCreate — directory move detection ─────────────────────────────────
describe('handleCreate — directory move detection', () => {
it('detects moved books in a renamed directory via inode matching', async () => {
mockStat
.mockResolvedValueOnce(makeFileStat({ isDirectory: true, isFile: false }))
.mockResolvedValueOnce(makeFileStat({ ino: 6000, size: 90000 }));
mockRepo.findMissingBooksByFolderPath.mockResolvedValue([]);
mockReaddir.mockResolvedValue([
{ isFile: () => true, name: 'book.epub', parentPath: '/books/NewAuthor' },
] as any);
mockRepo.findBookFileWithContextByIno.mockResolvedValue({
file: { id: 70, bookId: 50, absolutePath: '/books/OldAuthor/book.epub' },
libraryId: 4,
folderPath: '/books/OldAuthor',
libraryFolderPath: '/books',
bookStatus: 'missing',
} as any);
const result = await makeService().handleCreate('/books/NewAuthor');
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(
70,
expect.objectContaining({ absolutePath: '/books/NewAuthor/book.epub' }),
);
expect(mockRepo.updateBookFolderPath).toHaveBeenCalledWith(50, '/books/NewAuthor');
expect(result).toEqual({ type: 'book-moved', libraryId: 4, bookIds: [50] });
});
it('returns noop when renamed directory has no matching inodes', async () => {
mockStat.mockResolvedValueOnce(makeFileStat({ isDirectory: true, isFile: false }));
mockRepo.findMissingBooksByFolderPath.mockResolvedValue([]);
mockReaddir.mockResolvedValue([]);
const result = await makeService().handleCreate('/books/EmptyDir');
expect(result).toEqual({ type: 'noop' });
});
});
// ── reconcileMissingBooks ──────────────────────────────────────────────────── // ── reconcileMissingBooks ────────────────────────────────────────────────────
describe('reconcileMissingBooks', () => { describe('reconcileMissingBooks', () => {
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { stat } from 'fs/promises'; import { readdir, stat } from 'fs/promises';
import { dirname } from 'path'; import { dirname, join, relative } from 'path';
import { classifyFile } from './lib/classify'; import { classifyFile } from './lib/classify';
import { ScannerRepository } from './scanner.repository'; import { ScannerRepository } from './scanner.repository';
@@ -8,6 +8,7 @@ import { ScannerRepository } from './scanner.repository';
export type FileEventResult = export type FileEventResult =
| { type: 'book-missing'; libraryId: number; bookIds: number[] } | { type: 'book-missing'; libraryId: number; bookIds: number[] }
| { type: 'book-restored'; libraryId: number; bookIds: number[] } | { type: 'book-restored'; libraryId: number; bookIds: number[] }
| { type: 'book-moved'; libraryId: number; bookIds: number[] }
| { type: 'noop' }; | { type: 'noop' };
@Injectable() @Injectable()
@@ -68,22 +69,24 @@ export class FileEventProcessorService {
const book = const book =
(await this.scannerRepo.findMissingBookByFolderPath(folderPath)) ?? (await this.scannerRepo.findMissingBookByFolderPath(folderPath)) ??
(await this.scannerRepo.findMissingBookByFolderPath(absolutePath)); (await this.scannerRepo.findMissingBookByFolderPath(absolutePath));
if (!book) return { type: 'noop' }; if (book) {
await this.scannerRepo.createBookFile({
bookId: book.id,
libraryFolderId: book.libraryFolderId,
absolutePath,
ino: BigInt(fileStat.ino),
sizeBytes: BigInt(fileStat.size),
mtime: fileStat.mtime,
format,
role: 'primary',
});
await this.scannerRepo.createBookFile({ await this.scannerRepo.markBooksAsPresent([book.id]);
bookId: book.id, this.logger.log(`Book ${book.id} restored — file returned: ${absolutePath}`);
libraryFolderId: book.libraryFolderId, return { type: 'book-restored', libraryId: book.libraryId, bookIds: [book.id] };
absolutePath, }
ino: BigInt(fileStat.ino),
sizeBytes: BigInt(fileStat.size),
mtime: fileStat.mtime,
format,
role: 'primary',
});
await this.scannerRepo.markBooksAsPresent([book.id]); return this.detectMovedFile(absolutePath, fileStat);
this.logger.log(`Book ${book.id} restored — file returned: ${absolutePath}`);
return { type: 'book-restored', libraryId: book.libraryId, bookIds: [book.id] };
} }
async reconcileMissingBooks(libraryIds: number[]): Promise<FileEventResult[]> { async reconcileMissingBooks(libraryIds: number[]): Promise<FileEventResult[]> {
@@ -125,21 +128,74 @@ export class FileEventProcessorService {
return { type: 'noop' }; return { type: 'noop' };
} }
private async handleCreateDir(absolutePath: string): Promise<FileEventResult> { private async detectMovedFile(newAbsolutePath: string, fileStat: Awaited<ReturnType<typeof stat>>): Promise<FileEventResult> {
const missingBooks = await this.scannerRepo.findMissingBooksByFolderPath(absolutePath); const match = await this.scannerRepo.findBookFileWithContextByIno(fileStat.ino);
if (missingBooks.length === 0) return { type: 'noop' }; if (!match || match.file.absolutePath === newAbsolutePath) return { type: 'noop' };
const restoredIds: number[] = []; const { file, libraryId, folderPath: oldFolderPath, libraryFolderPath } = match;
const libraryId = missingBooks[0]!.libraryId; const newFolderPath =
oldFolderPath === file.absolutePath ? newAbsolutePath : dirname(newAbsolutePath);
for (const book of missingBooks) { await this.scannerRepo.updateBookFile(file.id, {
const result = await this.tryRestoreBook(book); absolutePath: newAbsolutePath,
if (result.type !== 'noop') restoredIds.push(book.id); relPath: relative(libraryFolderPath, newAbsolutePath),
ino: BigInt(fileStat.ino),
sizeBytes: BigInt(fileStat.size),
mtime: fileStat.mtime,
});
if (newFolderPath !== oldFolderPath) {
await this.scannerRepo.updateBookFolderPath(file.bookId, newFolderPath);
} }
if (restoredIds.length === 0) return { type: 'noop' }; await this.scannerRepo.markBooksAsPresent([file.bookId]);
this.logger.log(`Book ${file.bookId} moved: ${file.absolutePath} → ${newAbsolutePath}`);
return { type: 'book-moved', libraryId, bookIds: [file.bookId] };
}
this.logger.log(`${restoredIds.length} book(s) restored: folder returned: ${absolutePath}`); private async detectMovedBooksInDir(dirPath: string): Promise<FileEventResult> {
return { type: 'book-restored', libraryId, bookIds: restoredIds }; const entries = await readdir(dirPath, { recursive: true, withFileTypes: true }).catch(() => []);
const movedBookIds: number[] = [];
let libraryId: number | null = null;
for (const entry of entries) {
if (!entry.isFile()) continue;
const fullPath = join(entry.parentPath ?? entry.path, entry.name);
const { role } = classifyFile(fullPath);
if (role !== 'primary') continue;
const s = await stat(fullPath).catch(() => null);
if (!s) continue;
const result = await this.detectMovedFile(fullPath, s);
if (result.type === 'book-moved') {
movedBookIds.push(...result.bookIds);
libraryId = result.libraryId;
}
}
if (movedBookIds.length === 0 || !libraryId) return { type: 'noop' };
this.logger.log(`${movedBookIds.length} book(s) moved: folder appeared: ${dirPath}`);
return { type: 'book-moved', libraryId, bookIds: movedBookIds };
}
private async handleCreateDir(absolutePath: string): Promise<FileEventResult> {
const missingBooks = await this.scannerRepo.findMissingBooksByFolderPath(absolutePath);
if (missingBooks.length > 0) {
const restoredIds: number[] = [];
const libraryId = missingBooks[0]!.libraryId;
for (const book of missingBooks) {
const result = await this.tryRestoreBook(book);
if (result.type !== 'noop') restoredIds.push(book.id);
}
if (restoredIds.length > 0) {
this.logger.log(`${restoredIds.length} book(s) restored: folder returned: ${absolutePath}`);
return { type: 'book-restored', libraryId, bookIds: restoredIds };
}
}
return this.detectMovedBooksInDir(absolutePath);
} }
} }
@@ -123,6 +123,8 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
this.gateway.emitBookMissing({ libraryId: result.libraryId, bookIds: result.bookIds }); this.gateway.emitBookMissing({ libraryId: result.libraryId, bookIds: result.bookIds });
} else if (result.type === 'book-restored') { } else if (result.type === 'book-restored') {
this.gateway.emitBookRestored({ libraryId: result.libraryId, bookIds: result.bookIds }); this.gateway.emitBookRestored({ libraryId: result.libraryId, bookIds: result.bookIds });
} else if (result.type === 'book-moved') {
this.gateway.emitBookMoved({ libraryId: result.libraryId, bookIds: result.bookIds });
} }
} }
} }
+5 -1
View File
@@ -3,7 +3,7 @@ import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; import { OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import type { BookMissingEvent, BookRestoredEvent, CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types'; import type { BookMissingEvent, BookMovedEvent, BookRestoredEvent, CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { ScanJobStore } from './scan-job-store.service'; import { ScanJobStore } from './scan-job-store.service';
@@ -78,4 +78,8 @@ export class ScanGateway implements OnGatewayConnection, OnGatewayDisconnect {
emitBookRestored(event: BookRestoredEvent): void { emitBookRestored(event: BookRestoredEvent): void {
this.server?.to(`library:${event.libraryId}`).emit('book:restored', event); this.server?.to(`library:${event.libraryId}`).emit('book:restored', event);
} }
emitBookMoved(event: BookMovedEvent): void {
this.server?.to(`library:${event.libraryId}`).emit('book:moved', event);
}
} }
@@ -183,4 +183,25 @@ export class ScannerRepository {
if (bookIds.length === 0) return; if (bookIds.length === 0) return;
await this.db.update(books).set({ status: 'present', updatedAt: new Date() }).where(inArray(books.id, bookIds)); await this.db.update(books).set({ status: 'present', updatedAt: new Date() }).where(inArray(books.id, bookIds));
} }
async findBookFileWithContextByIno(ino: number) {
const [row] = await this.db
.select({
file: bookFiles,
libraryId: books.libraryId,
bookStatus: books.status,
folderPath: books.folderPath,
libraryFolderPath: libraryFolders.path,
})
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.innerJoin(libraryFolders, eq(libraryFolders.id, bookFiles.libraryFolderId))
.where(eq(bookFiles.ino, ino))
.limit(1);
return row ?? null;
}
async updateBookFolderPath(bookId: number, folderPath: string) {
await this.db.update(books).set({ folderPath, updatedAt: new Date() }).where(eq(books.id, bookId));
}
} }