mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -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 -->
|
<!-- 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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -31,3 +31,8 @@ export interface BookRestoredEvent {
|
|||||||
libraryId: number;
|
libraryId: number;
|
||||||
bookIds: number[];
|
bookIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BookMovedEvent {
|
||||||
|
libraryId: number;
|
||||||
|
bookIds: number[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user