diff --git a/admin/app/controllers/zim_controller.ts b/admin/app/controllers/zim_controller.ts index 006e59b..0087e76 100644 --- a/admin/app/controllers/zim_controller.ts +++ b/admin/app/controllers/zim_controller.ts @@ -56,6 +56,14 @@ export default class ZimController { } } + async rescanLibrary({}: HttpContext) { + const result = await this.zimService.rescanLibrary() + return { + message: 'Kiwix library rescanned', + ...result, + } + } + async delete({ request, response }: HttpContext) { const payload = await request.validateUsing(filenameParamValidator) diff --git a/admin/app/services/kiwix_library_service.ts b/admin/app/services/kiwix_library_service.ts index c7aeabd..183afe6 100644 --- a/admin/app/services/kiwix_library_service.ts +++ b/admin/app/services/kiwix_library_service.ts @@ -187,7 +187,21 @@ export class KiwixLibraryService { .filter((b) => b.id && b.path) } - async rebuildFromDisk(opts?: { excludeFilenames?: string[] }): Promise { + /** + * Returns the number of books currently listed in the library XML, or 0 if the + * file doesn't exist yet. Used to report a before/after delta on a manual rescan. + */ + async getBookCount(): Promise { + try { + const content = await readFile(this.getLibraryFilePath(), 'utf-8') + return this._parseExistingBooks(content).length + } catch (err: any) { + if (err.code === 'ENOENT') return 0 + throw err + } + } + + async rebuildFromDisk(opts?: { excludeFilenames?: string[] }): Promise { const dirPath = join(process.cwd(), ZIM_STORAGE_PATH) await ensureDirectoryExists(dirPath) @@ -221,6 +235,7 @@ export class KiwixLibraryService { const xml = this._buildXml(books) await this._atomicWrite(xml) logger.info(`[KiwixLibraryService] Rebuilt library XML with ${books.length} book(s).`) + return books.length } async addBook(filename: string): Promise { diff --git a/admin/app/services/zim_service.ts b/admin/app/services/zim_service.ts index 4c8d430..aeaa8ff 100644 --- a/admin/app/services/zim_service.ts +++ b/admin/app/services/zim_service.ts @@ -390,6 +390,36 @@ export class ZimService { } } + /** + * Rebuilds the kiwix library XML from whatever ZIM files are currently on disk. + * + * This is the manual counterpart to the automatic rebuilds that run after a + * download or delete. It exists for the sideload case: a user copies a .zim file + * onto the box (USB, SSH, network share) outside the download flow, and kiwix has + * no way to discover it without regenerating the library index. + * + * In library mode (--monitorLibrary) kiwix-serve hot-reloads the XML on its own, so + * no restart is needed. Only legacy glob-mode containers are restarted to pick up + * the change. Returns the book count before and after plus the number added. + */ + async rescanLibrary(): Promise<{ before: number; after: number; added: number }> { + const kiwixLibraryService = new KiwixLibraryService() + const before = await kiwixLibraryService.getBookCount() + const after = await kiwixLibraryService.rebuildFromDisk() + + const isLegacy = await this.dockerService.isKiwixOnLegacyConfig() + if (isLegacy) { + logger.info('[ZimService] Kiwix in legacy mode — restarting container after rescan.') + await this.dockerService + .affectContainer(SERVICE_NAMES.KIWIX, 'restart') + .catch((error) => { + logger.error('[ZimService] Failed to restart KIWIX container after rescan:', error) + }) + } + + return { before, after, added: Math.max(0, after - before) } + } + async delete(file: string): Promise { let fileName = file if (!fileName.endsWith('.zim')) { diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index b6372e9..83950a9 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -762,6 +762,18 @@ class API { })() } + async rescanZimLibrary() { + return catchInternal(async () => { + const response = await this.client.post<{ + message: string + before: number + after: number + added: number + }>('/zim/rescan-library') + return response.data + })() + } + async listDownloadJobs(filetype?: string): Promise { return catchInternal(async () => { const endpoint = filetype ? `/downloads/jobs/${filetype}` : '/downloads/jobs' diff --git a/admin/inertia/pages/settings/zim/index.tsx b/admin/inertia/pages/settings/zim/index.tsx index 68ae2b1..2c727dc 100644 --- a/admin/inertia/pages/settings/zim/index.tsx +++ b/admin/inertia/pages/settings/zim/index.tsx @@ -9,6 +9,7 @@ import { useModals } from '~/context/ModalContext' import StyledModal from '~/components/StyledModal' import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus' import Alert from '~/components/Alert' +import { useNotifications } from '~/context/NotificationContext' import { ZimFileWithMetadata } from '../../../../types/zim' import { SERVICE_NAMES } from '../../../../constants/service_names' import { formatBytes } from '~/lib/util' @@ -20,6 +21,7 @@ type SortDirection = 'asc' | 'desc' export default function ZimPage() { const queryClient = useQueryClient() const { openModal, closeAllModals } = useModals() + const { addNotification } = useNotifications() const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX) const [sortKey, setSortKey] = useState('size') const [sortDirection, setSortDirection] = useState('desc') @@ -105,18 +107,45 @@ export default function ZimPage() { }, }) + const rescanMutation = useMutation({ + mutationFn: async () => api.rescanZimLibrary(), + onSuccess: (result) => { + // catchInternal returns undefined on error (and shows its own error toast) + if (!result) return + queryClient.invalidateQueries({ queryKey: ['zim-files'] }) + addNotification({ + type: 'success', + message: + result.added > 0 + ? `Found ${result.added} new ${result.added === 1 ? 'book' : 'books'}. Library now has ${result.after}.` + : `Library is up to date (${result.after} ${result.after === 1 ? 'book' : 'books'}).`, + }) + }, + }) + return (
-
+

Content Manager

Manage your stored content files.

+ {isInstalled && ( + rescanMutation.mutate()} + > + Rescan Library + + )}
{!isInstalled && (