mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(scanner): detect moved books via inode matching and propagate
This commit is contained in:
@@ -52,7 +52,7 @@ function openFile(file: BookFileRef) {
|
||||
<!-- Cover -->
|
||||
<div
|
||||
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="coverLoaded ? {} : coverStyle"
|
||||
>
|
||||
@@ -60,7 +60,7 @@ function openFile(file: BookFileRef) {
|
||||
v-if="!coverFailed"
|
||||
:src="coverSrc"
|
||||
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"
|
||||
:alt="book.title ?? ''"
|
||||
@load="coverLoaded = true"
|
||||
@@ -105,7 +105,7 @@ function openFile(file: BookFileRef) {
|
||||
<!-- Center: primary action buttons -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-[18cqi]">
|
||||
<button
|
||||
v-if="primaryFile"
|
||||
v-if="primaryFile && !isMissing"
|
||||
class="p-[7cqi] rounded-full bg-primary/50 hover:bg-primary transition-colors text-white"
|
||||
title="Read"
|
||||
@click="openFile(primaryFile)"
|
||||
|
||||
@@ -43,11 +43,11 @@ const presentBook: BookCard = {
|
||||
}
|
||||
|
||||
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 coverDiv = wrapper.find('[style*="aspect-ratio"]')
|
||||
expect(coverDiv.classes()).toContain('grayscale')
|
||||
expect(coverDiv.classes()).toContain('opacity-60')
|
||||
expect(coverDiv.classes()).toContain('ring-2')
|
||||
expect(coverDiv.classes()).toContain('ring-amber-500')
|
||||
})
|
||||
|
||||
it('does not apply hover-scale to the cover container', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
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 { bookCoverStyle } from '@/features/book/lib/book-cover'
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
@@ -142,6 +142,14 @@ function downloadFile() {
|
||||
</span>
|
||||
</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 -->
|
||||
<dl
|
||||
v-if="book.publisher || book.publishedYear || book.language || book.pageCount || book.isbn13 || book.isbn10"
|
||||
|
||||
@@ -50,6 +50,7 @@ function downloadFile(file: BookDetailFile) {
|
||||
<!-- File info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<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">
|
||||
{{ formatBytes(file.sizeBytes) }}
|
||||
<span class="mx-1">·</span>
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
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'
|
||||
|
||||
type BookMissingCallback = (bookIds: number[]) => void
|
||||
type BookRestoredCallback = (bookIds: number[]) => void
|
||||
type BookIdsCallback = (bookIds: number[]) => void
|
||||
|
||||
const missingCallbacks = new Set<BookMissingCallback>()
|
||||
const restoredCallbacks = new Set<BookRestoredCallback>()
|
||||
const missingCallbacks = new Set<BookIdsCallback>()
|
||||
const restoredCallbacks = new Set<BookIdsCallback>()
|
||||
const movedCallbacks = new Set<BookIdsCallback>()
|
||||
|
||||
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() {
|
||||
if (pendingMissingCount === 0) return
|
||||
const count = pendingMissingCount
|
||||
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.`)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
function ensureInitialized() {
|
||||
@@ -30,27 +40,39 @@ function ensureInitialized() {
|
||||
socket.on('book:missing', (event: BookMissingEvent) => {
|
||||
for (const cb of missingCallbacks) cb(event.bookIds)
|
||||
pendingMissingCount += event.bookIds.length
|
||||
clearTimeout(toastTimer ?? undefined)
|
||||
toastTimer = setTimeout(flushMissingToast, 1000)
|
||||
clearTimeout(missingToastTimer ?? undefined)
|
||||
missingToastTimer = setTimeout(flushMissingToast, 1000)
|
||||
})
|
||||
|
||||
socket.on('book:restored', (event: BookRestoredEvent) => {
|
||||
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() {
|
||||
ensureInitialized()
|
||||
|
||||
function onBookMissing(cb: BookMissingCallback): () => void {
|
||||
function onBookMissing(cb: BookIdsCallback): () => void {
|
||||
missingCallbacks.add(cb)
|
||||
return () => missingCallbacks.delete(cb)
|
||||
}
|
||||
|
||||
function onBookRestored(cb: BookRestoredCallback): () => void {
|
||||
function onBookRestored(cb: BookIdsCallback): () => void {
|
||||
restoredCallbacks.add(cb)
|
||||
return () => restoredCallbacks.delete(cb)
|
||||
}
|
||||
|
||||
return { onBookMissing, onBookRestored }
|
||||
function onBookMoved(cb: BookIdsCallback): () => void {
|
||||
movedCallbacks.add(cb)
|
||||
return () => movedCallbacks.delete(cb)
|
||||
}
|
||||
|
||||
return { onBookMissing, onBookRestored, onBookMoved }
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const { libraries, fetchLibraries } = useLibraries()
|
||||
const { subscribeLibrary } = useScanProgress()
|
||||
watch(() => detail.value?.libraryId, (id) => { if (id !== undefined) subscribeLibrary(id) })
|
||||
|
||||
const { onBookMissing, onBookRestored } = useBookEvents()
|
||||
const { onBookMissing, onBookRestored, onBookMoved } = useBookEvents()
|
||||
onBookMissing((bookIds) => {
|
||||
if (detail.value && bookIds.includes(detail.value.id)) {
|
||||
detail.value = { ...detail.value, status: 'missing' }
|
||||
@@ -34,6 +34,9 @@ onBookMissing((bookIds) => {
|
||||
onBookRestored((bookIds) => {
|
||||
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 })
|
||||
fetchLibraries()
|
||||
@@ -60,14 +63,14 @@ watch(activeTab, (tab) => visitedTabs.add(tab), { immediate: true })
|
||||
<template>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset class="flex flex-col min-h-screen">
|
||||
<SidebarInset class="flex flex-col min-h-screen overflow-x-hidden">
|
||||
<AppHeader />
|
||||
<div class="flex items-center border-b shrink-0 h-11">
|
||||
<BookDetailHeader :library-name="libraryName" :loading="loading" @back="goBack" />
|
||||
<BookDetailTabs :active-tab="activeTab" @update:active-tab="setTab" />
|
||||
</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">
|
||||
<DetailsTab v-if="visitedTabs.has('details')" v-show="activeTab === 'details'" :book="detail" />
|
||||
<FilesTab v-if="visitedTabs.has('files')" v-show="activeTab === 'files'" :book="detail" />
|
||||
|
||||
@@ -34,7 +34,7 @@ const { items: books, total, loading, error, filter, sort, hasMore, load, clear
|
||||
const { subscribeLibrary } = useScanProgress()
|
||||
watch(libraryId, (id) => { if (id !== null) subscribeLibrary(id) }, { immediate: true })
|
||||
|
||||
const { onBookMissing, onBookRestored } = useBookEvents()
|
||||
const { onBookMissing, onBookRestored, onBookMoved } = useBookEvents()
|
||||
onBookMissing((bookIds) => {
|
||||
const missing = new Set(bookIds)
|
||||
for (const book of books.value) {
|
||||
@@ -47,6 +47,12 @@ onBookRestored((bookIds) => {
|
||||
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)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export type BookDetailFile = {
|
||||
format: string | null;
|
||||
role: string;
|
||||
sizeBytes: number | null;
|
||||
absolutePath: string;
|
||||
createdAt: string;
|
||||
filename: string | null;
|
||||
};
|
||||
@@ -27,6 +28,7 @@ export type BookDetail = {
|
||||
id: number;
|
||||
libraryId: number;
|
||||
status: string;
|
||||
folderPath: string;
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
description: string | null;
|
||||
|
||||
@@ -31,3 +31,8 @@ export interface BookRestoredEvent {
|
||||
libraryId: number;
|
||||
bookIds: number[];
|
||||
}
|
||||
|
||||
export interface BookMovedEvent {
|
||||
libraryId: number;
|
||||
bookIds: number[];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1769672547955,
|
||||
"when": 1769817612746,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ import { ScannerRepository } from './scanner.repository';
|
||||
|
||||
jest.mock('fs/promises', () => ({
|
||||
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 mockReaddir = readdir as jest.MockedFunction<typeof readdir>;
|
||||
|
||||
const mockRepo: jest.Mocked<
|
||||
Pick<
|
||||
@@ -22,6 +24,8 @@ const mockRepo: jest.Mocked<
|
||||
| 'createBookFile'
|
||||
| 'updateBookFile'
|
||||
| 'markBooksAsPresent'
|
||||
| 'findBookFileWithContextByIno'
|
||||
| 'updateBookFolderPath'
|
||||
>
|
||||
> = {
|
||||
findBookFileByAbsolutePath: jest.fn(),
|
||||
@@ -34,6 +38,8 @@ const mockRepo: jest.Mocked<
|
||||
createBookFile: jest.fn(),
|
||||
updateBookFile: jest.fn(),
|
||||
markBooksAsPresent: jest.fn(),
|
||||
findBookFileWithContextByIno: jest.fn(),
|
||||
updateBookFolderPath: jest.fn(),
|
||||
};
|
||||
|
||||
function makeService() {
|
||||
@@ -56,6 +62,9 @@ beforeEach(() => {
|
||||
mockRepo.markBooksAsPresent.mockResolvedValue(undefined);
|
||||
mockRepo.createBookFile.mockResolvedValue({} as any);
|
||||
mockRepo.updateBookFile.mockResolvedValue({} as any);
|
||||
mockRepo.findBookFileWithContextByIno.mockResolvedValue(null);
|
||||
mockRepo.updateBookFolderPath.mockResolvedValue(undefined);
|
||||
mockReaddir.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
// ── 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 ────────────────────────────────────────────────────
|
||||
|
||||
describe('reconcileMissingBooks', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { stat } from 'fs/promises';
|
||||
import { dirname } from 'path';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import { dirname, join, relative } from 'path';
|
||||
|
||||
import { classifyFile } from './lib/classify';
|
||||
import { ScannerRepository } from './scanner.repository';
|
||||
@@ -8,6 +8,7 @@ import { ScannerRepository } from './scanner.repository';
|
||||
export type FileEventResult =
|
||||
| { type: 'book-missing'; libraryId: number; bookIds: number[] }
|
||||
| { type: 'book-restored'; libraryId: number; bookIds: number[] }
|
||||
| { type: 'book-moved'; libraryId: number; bookIds: number[] }
|
||||
| { type: 'noop' };
|
||||
|
||||
@Injectable()
|
||||
@@ -68,22 +69,24 @@ export class FileEventProcessorService {
|
||||
const book =
|
||||
(await this.scannerRepo.findMissingBookByFolderPath(folderPath)) ??
|
||||
(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({
|
||||
bookId: book.id,
|
||||
libraryFolderId: book.libraryFolderId,
|
||||
absolutePath,
|
||||
ino: BigInt(fileStat.ino),
|
||||
sizeBytes: BigInt(fileStat.size),
|
||||
mtime: fileStat.mtime,
|
||||
format,
|
||||
role: 'primary',
|
||||
});
|
||||
await this.scannerRepo.markBooksAsPresent([book.id]);
|
||||
this.logger.log(`Book ${book.id} restored — file returned: ${absolutePath}`);
|
||||
return { type: 'book-restored', libraryId: book.libraryId, bookIds: [book.id] };
|
||||
}
|
||||
|
||||
await this.scannerRepo.markBooksAsPresent([book.id]);
|
||||
this.logger.log(`Book ${book.id} restored — file returned: ${absolutePath}`);
|
||||
return { type: 'book-restored', libraryId: book.libraryId, bookIds: [book.id] };
|
||||
return this.detectMovedFile(absolutePath, fileStat);
|
||||
}
|
||||
|
||||
async reconcileMissingBooks(libraryIds: number[]): Promise<FileEventResult[]> {
|
||||
@@ -125,21 +128,74 @@ export class FileEventProcessorService {
|
||||
return { type: 'noop' };
|
||||
}
|
||||
|
||||
private async handleCreateDir(absolutePath: string): Promise<FileEventResult> {
|
||||
const missingBooks = await this.scannerRepo.findMissingBooksByFolderPath(absolutePath);
|
||||
if (missingBooks.length === 0) return { type: 'noop' };
|
||||
private async detectMovedFile(newAbsolutePath: string, fileStat: Awaited<ReturnType<typeof stat>>): Promise<FileEventResult> {
|
||||
const match = await this.scannerRepo.findBookFileWithContextByIno(fileStat.ino);
|
||||
if (!match || match.file.absolutePath === newAbsolutePath) return { type: 'noop' };
|
||||
|
||||
const restoredIds: number[] = [];
|
||||
const libraryId = missingBooks[0]!.libraryId;
|
||||
const { file, libraryId, folderPath: oldFolderPath, libraryFolderPath } = match;
|
||||
const newFolderPath =
|
||||
oldFolderPath === file.absolutePath ? newAbsolutePath : dirname(newAbsolutePath);
|
||||
|
||||
for (const book of missingBooks) {
|
||||
const result = await this.tryRestoreBook(book);
|
||||
if (result.type !== 'noop') restoredIds.push(book.id);
|
||||
await this.scannerRepo.updateBookFile(file.id, {
|
||||
absolutePath: newAbsolutePath,
|
||||
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}`);
|
||||
return { type: 'book-restored', libraryId, bookIds: restoredIds };
|
||||
private async detectMovedBooksInDir(dirPath: string): Promise<FileEventResult> {
|
||||
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 });
|
||||
} else if (result.type === 'book-restored') {
|
||||
this.gateway.emitBookRestored({ libraryId: result.libraryId, bookIds: result.bookIds });
|
||||
} else if (result.type === 'book-moved') {
|
||||
this.gateway.emitBookMoved({ libraryId: result.libraryId, bookIds: result.bookIds });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||
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 { ScanJobStore } from './scan-job-store.service';
|
||||
|
||||
@@ -78,4 +78,8 @@ export class ScanGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
emitBookRestored(event: BookRestoredEvent): void {
|
||||
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;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user