From 760cc6476fe921fab8a830db5350f642fbc06464 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:13:07 -0600 Subject: [PATCH] feat(library): add creator wizard, folder picker, and full CRUD --- .../library/components/FolderPickerModal.vue | 170 +++++++ .../components/LibraryCreatorAccess.vue | 155 +++++++ .../components/LibraryCreatorDetails.vue | 129 ++++++ .../components/LibraryCreatorFolders.vue | 196 ++++++++ .../components/LibraryCreatorModal.vue | 432 ++++++++++++++++++ .../components/LibraryCreatorScanner.vue | 212 +++++++++ .../components/LibraryCreatorSchedule.vue | 123 +++++ .../library/composables/useLibraryCreator.ts | 147 ++++++ .../features/settings/LibrariesSettings.vue | 129 ++++-- docker-compose.yml | 1 + packages/types/src/index.ts | 3 +- packages/types/src/library.ts | 52 +++ server/src/app.module.ts | 2 + ..._add_oidc_schema.sql => 0000_baseline.sql} | 10 + .../src/db/migrations/meta/0000_snapshot.json | 69 ++- server/src/db/migrations/meta/_journal.json | 4 +- server/src/db/schema/libraries.ts | 24 +- .../modules/library/dto/create-library.dto.ts | 67 +++ .../library/dto/prescan-library.dto.ts | 9 + .../library/dto/reorder-libraries.dto.ts | 19 + .../modules/library/dto/update-library.dto.ts | 69 +++ .../src/modules/library/library.controller.ts | 48 ++ .../src/modules/library/library.repository.ts | 111 ++++- server/src/modules/library/library.service.ts | 149 +++++- .../src/modules/metadata/metadata.service.ts | 11 +- server/src/modules/path/path.controller.ts | 15 + server/src/modules/path/path.module.ts | 10 + server/src/modules/path/path.service.ts | 33 ++ 28 files changed, 2346 insertions(+), 53 deletions(-) create mode 100644 client/src/features/library/components/FolderPickerModal.vue create mode 100644 client/src/features/library/components/LibraryCreatorAccess.vue create mode 100644 client/src/features/library/components/LibraryCreatorDetails.vue create mode 100644 client/src/features/library/components/LibraryCreatorFolders.vue create mode 100644 client/src/features/library/components/LibraryCreatorModal.vue create mode 100644 client/src/features/library/components/LibraryCreatorScanner.vue create mode 100644 client/src/features/library/components/LibraryCreatorSchedule.vue create mode 100644 client/src/features/library/composables/useLibraryCreator.ts create mode 100644 packages/types/src/library.ts rename server/src/db/migrations/{0000_add_oidc_schema.sql => 0000_baseline.sql} (95%) create mode 100644 server/src/modules/library/dto/create-library.dto.ts create mode 100644 server/src/modules/library/dto/prescan-library.dto.ts create mode 100644 server/src/modules/library/dto/reorder-libraries.dto.ts create mode 100644 server/src/modules/library/dto/update-library.dto.ts create mode 100644 server/src/modules/path/path.controller.ts create mode 100644 server/src/modules/path/path.module.ts create mode 100644 server/src/modules/path/path.service.ts diff --git a/client/src/features/library/components/FolderPickerModal.vue b/client/src/features/library/components/FolderPickerModal.vue new file mode 100644 index 000000000..64a347a62 --- /dev/null +++ b/client/src/features/library/components/FolderPickerModal.vue @@ -0,0 +1,170 @@ + + + diff --git a/client/src/features/library/components/LibraryCreatorAccess.vue b/client/src/features/library/components/LibraryCreatorAccess.vue new file mode 100644 index 000000000..619332d04 --- /dev/null +++ b/client/src/features/library/components/LibraryCreatorAccess.vue @@ -0,0 +1,155 @@ + + + diff --git a/client/src/features/library/components/LibraryCreatorDetails.vue b/client/src/features/library/components/LibraryCreatorDetails.vue new file mode 100644 index 000000000..bdb9603fd --- /dev/null +++ b/client/src/features/library/components/LibraryCreatorDetails.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/client/src/features/library/components/LibraryCreatorFolders.vue b/client/src/features/library/components/LibraryCreatorFolders.vue new file mode 100644 index 000000000..5176a5a69 --- /dev/null +++ b/client/src/features/library/components/LibraryCreatorFolders.vue @@ -0,0 +1,196 @@ + + + diff --git a/client/src/features/library/components/LibraryCreatorModal.vue b/client/src/features/library/components/LibraryCreatorModal.vue new file mode 100644 index 000000000..51d485f37 --- /dev/null +++ b/client/src/features/library/components/LibraryCreatorModal.vue @@ -0,0 +1,432 @@ + + + + + diff --git a/client/src/features/library/components/LibraryCreatorScanner.vue b/client/src/features/library/components/LibraryCreatorScanner.vue new file mode 100644 index 000000000..02b1570da --- /dev/null +++ b/client/src/features/library/components/LibraryCreatorScanner.vue @@ -0,0 +1,212 @@ + + + diff --git a/client/src/features/library/components/LibraryCreatorSchedule.vue b/client/src/features/library/components/LibraryCreatorSchedule.vue new file mode 100644 index 000000000..c26d356fa --- /dev/null +++ b/client/src/features/library/components/LibraryCreatorSchedule.vue @@ -0,0 +1,123 @@ + + + diff --git a/client/src/features/library/composables/useLibraryCreator.ts b/client/src/features/library/composables/useLibraryCreator.ts new file mode 100644 index 000000000..2251a6790 --- /dev/null +++ b/client/src/features/library/composables/useLibraryCreator.ts @@ -0,0 +1,147 @@ +import { reactive, ref } from 'vue' +import { api } from '@/lib/api' +import type { Library, OrganizationMode, PrescanResult } from '@projectx/types' + +export const DEFAULT_METADATA_PRECEDENCE = ['embedded', 'opfFile'] +export const DEFAULT_FORMAT_PRIORITY = ['epub', 'pdf', 'cbz', 'cbr', 'mobi', 'azw3', 'fb2'] + +export const METADATA_LABELS: Record = { + embedded: 'Embedded metadata', + opfFile: 'OPF files', +} + +export const FORMAT_LABELS: Record = { + epub: 'EPUB', + pdf: 'PDF', + cbz: 'CBZ', + cbr: 'CBR', + mobi: 'MOBI', + azw3: 'AZW3', + fb2: 'FB2', +} + +function blankForm() { + return { + name: '', + icon: null as string | null, + displayOrder: 0, + folders: [] as string[], + watch: false, + autoScanCronExpression: null as string | null, + metadataPrecedence: [...DEFAULT_METADATA_PRECEDENCE], + formatPriority: [...DEFAULT_FORMAT_PRIORITY], + allowedFormats: [] as string[], + organizationMode: 'auto' as OrganizationMode, + excludePatterns: [] as string[], + markAsFinishedSecondsRemaining: null as number | null, + markAsFinishedPercentComplete: null as number | null, + } +} + +export function useLibraryCreator() { + const form = reactive(blankForm()) + const mode = ref<'create' | 'edit'>('create') + const editingLibraryId = ref(null) + const loading = ref(false) + const prescanLoading = ref(false) + const prescanResult = ref(null) + const error = ref(null) + + function initCreate() { + Object.assign(form, blankForm()) + mode.value = 'create' + editingLibraryId.value = null + prescanResult.value = null + error.value = null + } + + function initEdit(library: Library) { + form.name = library.name + form.icon = library.icon ?? null + form.displayOrder = library.displayOrder + form.folders = library.folders.map((f) => f.path) + form.watch = library.watch + form.autoScanCronExpression = library.autoScanCronExpression ?? null + form.metadataPrecedence = [...library.metadataPrecedence] + form.formatPriority = [...library.formatPriority] + form.allowedFormats = [...library.allowedFormats] + form.organizationMode = library.organizationMode + form.excludePatterns = [...library.excludePatterns] + form.markAsFinishedSecondsRemaining = library.markAsFinishedSecondsRemaining ?? null + form.markAsFinishedPercentComplete = library.markAsFinishedPercentComplete ?? null + mode.value = 'edit' + editingLibraryId.value = library.id + prescanResult.value = null + error.value = null + } + + async function runPrescan() { + if (form.folders.length === 0) return + prescanLoading.value = true + prescanResult.value = null + try { + const res = await api('/api/libraries/prescan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths: form.folders }), + }) + if (res.ok) { + prescanResult.value = await res.json() + } + } finally { + prescanLoading.value = false + } + } + + async function save(): Promise { + if (!form.name.trim()) { + error.value = 'Library name is required' + return null + } + if (form.folders.length === 0) { + error.value = 'At least one folder is required' + return null + } + error.value = null + loading.value = true + try { + const payload = { ...form } + let res: Response + if (mode.value === 'create') { + res = await api('/api/libraries', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + } else { + res = await api(`/api/libraries/${editingLibraryId.value}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + } + if (!res.ok) { + const body = await res.json().catch(() => ({})) + error.value = body?.message ?? 'Failed to save library' + return null + } + return await res.json() + } finally { + loading.value = false + } + } + + return { + form, + mode, + editingLibraryId, + loading, + prescanLoading, + prescanResult, + error, + initCreate, + initEdit, + runPrescan, + save, + } +} diff --git a/client/src/features/settings/LibrariesSettings.vue b/client/src/features/settings/LibrariesSettings.vue index 78ee218cb..bad8dbde9 100644 --- a/client/src/features/settings/LibrariesSettings.vue +++ b/client/src/features/settings/LibrariesSettings.vue @@ -1,24 +1,37 @@ diff --git a/docker-compose.yml b/docker-compose.yml index 29263059c..e6ebb6cc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-projectx} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-projectx} POSTGRES_DB: ${POSTGRES_DB:-projectx} + POSTGRES_HOST_AUTH_METHOD: trust ports: - "5432:5432" volumes: diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f140b2ec7..efbef2b91 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1 +1,2 @@ -export * from './auth' +export * from './auth'; +export * from './library'; diff --git a/packages/types/src/library.ts b/packages/types/src/library.ts new file mode 100644 index 000000000..a9ec05dce --- /dev/null +++ b/packages/types/src/library.ts @@ -0,0 +1,52 @@ +export type OrganizationMode = 'auto' | 'book_per_file' | 'book_per_folder'; +export type AccessLevel = 'viewer' | 'editor' | 'owner'; + +export interface LibraryFolder { + id: number; + path: string; + createdAt: string; +} + +export interface Library { + id: number; + name: string; + icon?: string | null; + displayOrder: number; + watch: boolean; + autoScanCronExpression?: string | null; + metadataPrecedence: string[]; + formatPriority: string[]; + allowedFormats: string[]; + organizationMode: OrganizationMode; + excludePatterns: string[]; + markAsFinishedSecondsRemaining?: number | null; + markAsFinishedPercentComplete?: number | null; + folders: LibraryFolder[]; + createdAt: string; + updatedAt: string; +} + +export interface LibraryStats { + totalBooks: number; + totalSizeBytes: number; + formatCounts: Record; +} + +export interface PrescanPathResult { + path: string; + accessible: boolean; + fileCount: number; + overlapLibrary?: string; +} + +export interface PrescanResult { + paths: PrescanPathResult[]; + totalFiles: number; +} + +export interface LibraryAccessEntry { + userId: number; + username: string; + name: string; + accessLevel: AccessLevel; +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 80efafcc6..86bd18450 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -17,6 +17,7 @@ import { BookmarkModule } from './modules/bookmark/bookmark.module'; import { BookModule } from './modules/book/book.module'; import { KoboModule } from './modules/kobo/kobo.module'; import { LibraryModule } from './modules/library/library.module'; +import { PathModule } from './modules/path/path.module'; import { MetadataModule } from './modules/metadata/metadata.module'; import { RoleModule } from './modules/role/role.module'; import { ScannerModule } from './modules/scanner/scanner.module'; @@ -38,6 +39,7 @@ import { UserModule } from './modules/user/user.module'; UserModule, RoleModule, LibraryModule, + PathModule, BookModule, ScannerModule, MetadataModule, diff --git a/server/src/db/migrations/0000_add_oidc_schema.sql b/server/src/db/migrations/0000_baseline.sql similarity index 95% rename from server/src/db/migrations/0000_add_oidc_schema.sql rename to server/src/db/migrations/0000_baseline.sql index 72a6e5a74..c681f37fd 100644 --- a/server/src/db/migrations/0000_add_oidc_schema.sql +++ b/server/src/db/migrations/0000_baseline.sql @@ -115,6 +115,16 @@ CREATE TABLE "libraries" ( "id" serial PRIMARY KEY NOT NULL, "name" varchar(255) NOT NULL, "icon" varchar(100), + "display_order" integer DEFAULT 0 NOT NULL, + "watch" boolean DEFAULT false NOT NULL, + "auto_scan_cron_expression" text, + "metadata_precedence" jsonb DEFAULT '["folderStructure","embedded","nfoFile","opfFile","sidecar"]'::jsonb NOT NULL, + "format_priority" jsonb DEFAULT '["epub","pdf","cbz","cbr","mobi","azw3","fb2"]'::jsonb NOT NULL, + "allowed_formats" jsonb DEFAULT '[]'::jsonb NOT NULL, + "organization_mode" varchar(20) DEFAULT 'auto' NOT NULL, + "exclude_patterns" jsonb DEFAULT '[]'::jsonb NOT NULL, + "mark_as_finished_seconds_remaining" integer, + "mark_as_finished_percent_complete" integer, "scan_mode" varchar(20) DEFAULT 'auto' NOT NULL, "poll_interval_seconds" integer DEFAULT 300, "created_at" timestamp DEFAULT now() NOT NULL, diff --git a/server/src/db/migrations/meta/0000_snapshot.json b/server/src/db/migrations/meta/0000_snapshot.json index 2b58ec4c9..ff90de0a4 100644 --- a/server/src/db/migrations/meta/0000_snapshot.json +++ b/server/src/db/migrations/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "8b1e1a78-207d-4b60-8237-1d13069c26e0", + "id": "05501c4f-0a30-4fac-961f-d84a298725a2", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -880,6 +880,73 @@ "primaryKey": false, "notNull": false }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "watch": { + "name": "watch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_scan_cron_expression": { + "name": "auto_scan_cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_precedence": { + "name": "metadata_precedence", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"folderStructure\",\"embedded\",\"nfoFile\",\"opfFile\",\"sidecar\"]'::jsonb" + }, + "format_priority": { + "name": "format_priority", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"epub\",\"pdf\",\"cbz\",\"cbr\",\"mobi\",\"azw3\",\"fb2\"]'::jsonb" + }, + "allowed_formats": { + "name": "allowed_formats", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "organization_mode": { + "name": "organization_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'auto'" + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "mark_as_finished_seconds_remaining": { + "name": "mark_as_finished_seconds_remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mark_as_finished_percent_complete": { + "name": "mark_as_finished_percent_complete", + "type": "integer", + "primaryKey": false, + "notNull": false + }, "scan_mode": { "name": "scan_mode", "type": "varchar(20)", diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json index 7b792b283..a7a1d4797 100644 --- a/server/src/db/migrations/meta/_journal.json +++ b/server/src/db/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1768382694329, - "tag": "0000_add_oidc_schema", + "when": 1768464558856, + "tag": "0000_baseline", "breakpoints": true } ] diff --git a/server/src/db/schema/libraries.ts b/server/src/db/schema/libraries.ts index 6dd2a7c40..ad5629ce9 100644 --- a/server/src/db/schema/libraries.ts +++ b/server/src/db/schema/libraries.ts @@ -1,11 +1,33 @@ -import { integer, pgTable, serial, timestamp, varchar } from 'drizzle-orm/pg-core'; +import { boolean, integer, jsonb, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core'; export const libraries = pgTable('libraries', { id: serial('id').primaryKey(), name: varchar('name', { length: 255 }).notNull(), icon: varchar('icon', { length: 100 }), + displayOrder: integer('display_order').notNull().default(0), + + // File watching & scheduling + watch: boolean('watch').notNull().default(false), + autoScanCronExpression: text('auto_scan_cron_expression'), + + // Scanner behaviour + metadataPrecedence: jsonb('metadata_precedence') + .$type() + .notNull() + .default(['folderStructure', 'embedded', 'nfoFile', 'opfFile', 'sidecar']), + formatPriority: jsonb('format_priority').$type().notNull().default(['epub', 'pdf', 'cbz', 'cbr', 'mobi', 'azw3', 'fb2']), + allowedFormats: jsonb('allowed_formats').$type().notNull().default([]), + organizationMode: varchar('organization_mode', { length: 20 }).notNull().default('auto'), + excludePatterns: jsonb('exclude_patterns').$type().notNull().default([]), + + // Reading progress thresholds + markAsFinishedSecondsRemaining: integer('mark_as_finished_seconds_remaining'), + markAsFinishedPercentComplete: integer('mark_as_finished_percent_complete'), + + // Legacy — kept for scanner compatibility scanMode: varchar('scan_mode', { length: 20 }).notNull().default('auto'), pollInterval: integer('poll_interval_seconds').default(300), + createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); diff --git a/server/src/modules/library/dto/create-library.dto.ts b/server/src/modules/library/dto/create-library.dto.ts new file mode 100644 index 000000000..530b0616c --- /dev/null +++ b/server/src/modules/library/dto/create-library.dto.ts @@ -0,0 +1,67 @@ +import { ArrayMinSize, IsArray, IsBoolean, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; + +export class CreateLibraryDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + name: string; + + @IsOptional() + @IsString() + @MaxLength(100) + icon?: string; + + @IsOptional() + @IsInt() + @Min(0) + displayOrder?: number; + + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + folders: string[]; + + @IsOptional() + @IsBoolean() + watch?: boolean; + + @IsOptional() + @IsString() + autoScanCronExpression?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + metadataPrecedence?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + formatPriority?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedFormats?: string[]; + + @IsOptional() + @IsIn(['auto', 'book_per_file', 'book_per_folder']) + organizationMode?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + excludePatterns?: string[]; + + @IsOptional() + @IsInt() + @Min(0) + markAsFinishedSecondsRemaining?: number | null; + + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + markAsFinishedPercentComplete?: number | null; +} diff --git a/server/src/modules/library/dto/prescan-library.dto.ts b/server/src/modules/library/dto/prescan-library.dto.ts new file mode 100644 index 000000000..0e25d9257 --- /dev/null +++ b/server/src/modules/library/dto/prescan-library.dto.ts @@ -0,0 +1,9 @@ +import { ArrayMinSize, IsArray, IsNotEmpty, IsString } from 'class-validator'; + +export class PrescanLibraryDto { + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + paths: string[]; +} diff --git a/server/src/modules/library/dto/reorder-libraries.dto.ts b/server/src/modules/library/dto/reorder-libraries.dto.ts new file mode 100644 index 000000000..f185167dc --- /dev/null +++ b/server/src/modules/library/dto/reorder-libraries.dto.ts @@ -0,0 +1,19 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsInt, Min, ValidateNested } from 'class-validator'; + +class LibraryOrderItem { + @IsInt() + @Min(1) + id: number; + + @IsInt() + @Min(0) + displayOrder: number; +} + +export class ReorderLibrariesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LibraryOrderItem) + order: LibraryOrderItem[]; +} diff --git a/server/src/modules/library/dto/update-library.dto.ts b/server/src/modules/library/dto/update-library.dto.ts new file mode 100644 index 000000000..8450865db --- /dev/null +++ b/server/src/modules/library/dto/update-library.dto.ts @@ -0,0 +1,69 @@ +import { ArrayMinSize, IsArray, IsBoolean, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; + +export class UpdateLibraryDto { + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + icon?: string | null; + + @IsOptional() + @IsInt() + @Min(0) + displayOrder?: number; + + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + folders?: string[]; + + @IsOptional() + @IsBoolean() + watch?: boolean; + + @IsOptional() + @IsString() + autoScanCronExpression?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + metadataPrecedence?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + formatPriority?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedFormats?: string[]; + + @IsOptional() + @IsIn(['auto', 'book_per_file', 'book_per_folder']) + organizationMode?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + excludePatterns?: string[]; + + @IsOptional() + @IsInt() + @Min(0) + markAsFinishedSecondsRemaining?: number | null; + + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + markAsFinishedPercentComplete?: number | null; +} diff --git a/server/src/modules/library/library.controller.ts b/server/src/modules/library/library.controller.ts index e54a84606..b022d772f 100644 --- a/server/src/modules/library/library.controller.ts +++ b/server/src/modules/library/library.controller.ts @@ -3,8 +3,12 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPip import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import type { RequestUser } from '../../common/types/request-user'; +import { CreateLibraryDto } from './dto/create-library.dto'; import { GrantLibraryAccessDto } from './dto/grant-library-access.dto'; +import { PrescanLibraryDto } from './dto/prescan-library.dto'; +import { ReorderLibrariesDto } from './dto/reorder-libraries.dto'; import { UpdateLibraryAccessDto } from './dto/update-library-access.dto'; +import { UpdateLibraryDto } from './dto/update-library.dto'; import { LibraryService } from './library.service'; @Controller('libraries') @@ -16,6 +20,50 @@ export class LibraryController { return this.libraryService.findAll(user); } + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) { + const isSuperuser = user.roles.some((r) => r.isSuperuser); + return this.libraryService.verifyUserAccess(user.id, id, isSuperuser).then(() => this.libraryService.findOne(id)); + } + + @Post() + @RequirePermission('manage_libraries') + create(@Body() dto: CreateLibraryDto) { + return this.libraryService.create(dto); + } + + @Patch(':id') + @RequirePermission('manage_libraries') + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateLibraryDto) { + return this.libraryService.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @RequirePermission('manage_libraries') + remove(@Param('id', ParseIntPipe) id: number) { + return this.libraryService.remove(id); + } + + @Post('prescan') + @RequirePermission('manage_libraries') + prescan(@Body() dto: PrescanLibraryDto) { + return this.libraryService.prescan(dto); + } + + @Post('reorder') + @HttpCode(HttpStatus.NO_CONTENT) + @RequirePermission('manage_libraries') + reorder(@Body() dto: ReorderLibrariesDto) { + return this.libraryService.reorder(dto); + } + + @Get(':id/stats') + getStats(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) { + const isSuperuser = user.roles.some((r) => r.isSuperuser); + return this.libraryService.verifyUserAccess(user.id, id, isSuperuser).then(() => this.libraryService.getStats(id)); + } + // ── Library access management ───────────────────────────────────────────── @Get(':libraryId/access') diff --git a/server/src/modules/library/library.repository.ts b/server/src/modules/library/library.repository.ts index ec80e3f01..da4de1b0d 100644 --- a/server/src/modules/library/library.repository.ts +++ b/server/src/modules/library/library.repository.ts @@ -1,10 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; -import { and, eq } from 'drizzle-orm'; +import { and, eq, inArray, sql } from 'drizzle-orm'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { DB } from '../../db'; import * as schema from '../../db/schema'; -import { libraryFolders, libraries } from '../../db/schema'; +import { bookFiles, books, libraryFolders, libraries } from '../../db/schema'; type Db = NodePgDatabase; @@ -13,29 +13,64 @@ export class LibraryRepository { constructor(@Inject(DB) private readonly db: Db) {} findAll() { - return this.db.select().from(libraries).orderBy(libraries.name); + return this.db.select().from(libraries).orderBy(libraries.displayOrder, libraries.name); } findAllForUser(userId: number) { return this.db - .select({ id: libraries.id, name: libraries.name, icon: libraries.icon, scanMode: libraries.scanMode }) + .select({ + id: libraries.id, + name: libraries.name, + icon: libraries.icon, + displayOrder: libraries.displayOrder, + scanMode: libraries.scanMode, + createdAt: libraries.createdAt, + updatedAt: libraries.updatedAt, + }) .from(libraries) .innerJoin(schema.userLibraryAccess, and(eq(schema.userLibraryAccess.libraryId, libraries.id), eq(schema.userLibraryAccess.userId, userId))) - .orderBy(libraries.name); + .orderBy(libraries.displayOrder, libraries.name); } findById(id: number) { return this.db.select().from(libraries).where(eq(libraries.id, id)).limit(1); } + findByName(name: string, excludeId?: number) { + return this.db + .select({ id: libraries.id }) + .from(libraries) + .where( + excludeId + ? and(eq(sql`lower(${libraries.name})`, name.toLowerCase()), sql`${libraries.id} != ${excludeId}`) + : eq(sql`lower(${libraries.name})`, name.toLowerCase()), + ) + .limit(1); + } + findFoldersByLibrary(libraryId: number) { return this.db.select().from(libraryFolders).where(eq(libraryFolders.libraryId, libraryId)); } + findAllFolderPaths() { + return this.db + .select({ libraryId: libraryFolders.libraryId, path: libraryFolders.path, libraryName: libraries.name }) + .from(libraryFolders) + .innerJoin(libraries, eq(libraries.id, libraryFolders.libraryId)); + } + insert(data: typeof libraries.$inferInsert) { return this.db.insert(libraries).values(data).returning(); } + update(id: number, data: Partial) { + return this.db + .update(libraries) + .set({ ...data, updatedAt: new Date() }) + .where(eq(libraries.id, id)) + .returning(); + } + insertFolder(data: typeof libraryFolders.$inferInsert) { return this.db.insert(libraryFolders).values(data).returning(); } @@ -48,6 +83,47 @@ export class LibraryRepository { return this.db.delete(libraryFolders).where(eq(libraryFolders.id, id)); } + deleteFoldersByLibrary(libraryId: number) { + return this.db.delete(libraryFolders).where(eq(libraryFolders.libraryId, libraryId)); + } + + async updateDisplayOrders(order: { id: number; displayOrder: number }[]) { + for (const item of order) { + await this.db.update(libraries).set({ displayOrder: item.displayOrder }).where(eq(libraries.id, item.id)); + } + } + + async getStats(libraryId: number) { + const [countRow] = await this.db + .select({ count: sql`count(*)::int` }) + .from(books) + .where(and(eq(books.libraryId, libraryId), eq(books.status, 'present'))); + + const formatRows = await this.db + .select({ + format: bookFiles.format, + count: sql`count(*)::int`, + totalSize: sql`coalesce(sum(${bookFiles.sizeBytes}), 0)::bigint`, + }) + .from(bookFiles) + .innerJoin(books, eq(books.id, bookFiles.bookId)) + .where(and(eq(books.libraryId, libraryId), eq(books.status, 'present'), eq(bookFiles.role, 'primary'))) + .groupBy(bookFiles.format); + + const formatCounts: Record = {}; + let totalSizeBytes = 0; + for (const row of formatRows) { + if (row.format) formatCounts[row.format] = row.count; + totalSizeBytes += Number(row.totalSize); + } + + return { + totalBooks: countRow?.count ?? 0, + totalSizeBytes, + formatCounts, + }; + } + async hasUserAccess(userId: number, libraryId: number): Promise { const row = await this.db.query.userLibraryAccess.findFirst({ where: and(eq(schema.userLibraryAccess.userId, userId), eq(schema.userLibraryAccess.libraryId, libraryId)), @@ -83,4 +159,29 @@ export class LibraryRepository { .delete(schema.userLibraryAccess) .where(and(eq(schema.userLibraryAccess.libraryId, libraryId), eq(schema.userLibraryAccess.userId, userId))); } + + getUsersNotInLibrary(libraryId: number) { + const subquery = this.db + .select({ userId: schema.userLibraryAccess.userId }) + .from(schema.userLibraryAccess) + .where(eq(schema.userLibraryAccess.libraryId, libraryId)); + + return this.db + .select({ id: schema.users.id, username: schema.users.username, name: schema.users.name }) + .from(schema.users) + .where(and(eq(schema.users.active, true), sql`${schema.users.id} not in (${subquery})`)); + } + + getAccessWithUsers(libraryId: number) { + return this.db + .select({ + userId: schema.userLibraryAccess.userId, + accessLevel: schema.userLibraryAccess.accessLevel, + username: schema.users.username, + name: schema.users.name, + }) + .from(schema.userLibraryAccess) + .innerJoin(schema.users, eq(schema.users.id, schema.userLibraryAccess.userId)) + .where(eq(schema.userLibraryAccess.libraryId, libraryId)); + } } diff --git a/server/src/modules/library/library.service.ts b/server/src/modules/library/library.service.ts index f7c3f5850..95d70b2e8 100644 --- a/server/src/modules/library/library.service.ts +++ b/server/src/modules/library/library.service.ts @@ -1,7 +1,14 @@ -import { ForbiddenException, Injectable } from '@nestjs/common'; +import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { access, readdir, stat } from 'fs/promises'; +import { join } from 'path'; import type { RequestUser } from '../../common/types/request-user'; +import { isPrimaryFormat } from '../scanner/lib/classify'; +import { CreateLibraryDto } from './dto/create-library.dto'; import { GrantLibraryAccessDto } from './dto/grant-library-access.dto'; +import { PrescanLibraryDto } from './dto/prescan-library.dto'; +import { ReorderLibrariesDto } from './dto/reorder-libraries.dto'; +import { UpdateLibraryDto } from './dto/update-library.dto'; import { LibraryRepository } from './library.repository'; @Injectable() @@ -19,8 +26,113 @@ export class LibraryService { return isSuperuser ? this.libraryRepo.findAll() : this.libraryRepo.findAllForUser(user.id); } + async findOne(id: number) { + const [library] = await this.libraryRepo.findById(id); + if (!library) throw new NotFoundException('Library not found'); + const folders = await this.libraryRepo.findFoldersByLibrary(id); + return { ...library, folders }; + } + + async create(dto: CreateLibraryDto) { + await this.assertNameAvailable(dto.name); + + const [library] = await this.libraryRepo.insert({ + name: dto.name, + icon: dto.icon ?? null, + displayOrder: dto.displayOrder ?? 0, + watch: dto.watch ?? false, + autoScanCronExpression: dto.autoScanCronExpression ?? null, + metadataPrecedence: dto.metadataPrecedence ?? ['folderStructure', 'embedded', 'nfoFile', 'opfFile', 'sidecar'], + formatPriority: dto.formatPriority ?? ['epub', 'pdf', 'cbz', 'cbr', 'mobi', 'azw3', 'fb2'], + allowedFormats: dto.allowedFormats ?? [], + organizationMode: dto.organizationMode ?? 'auto', + excludePatterns: dto.excludePatterns ?? [], + markAsFinishedSecondsRemaining: dto.markAsFinishedSecondsRemaining ?? null, + markAsFinishedPercentComplete: dto.markAsFinishedPercentComplete ?? null, + }); + + const folders = await Promise.all(dto.folders.map((path) => this.libraryRepo.insertFolder({ libraryId: library.id, path }))); + + return { ...library, folders: folders.map(([f]) => f) }; + } + + async update(id: number, dto: UpdateLibraryDto) { + const [existing] = await this.libraryRepo.findById(id); + if (!existing) throw new NotFoundException('Library not found'); + + if (dto.name && dto.name !== existing.name) { + await this.assertNameAvailable(dto.name, id); + } + + const { folders: folderPaths, ...fields } = dto; + + const [updated] = await this.libraryRepo.update(id, fields); + + if (folderPaths !== undefined) { + // Replace all folders: delete existing, insert new ones + await this.libraryRepo.deleteFoldersByLibrary(id); + await Promise.all(folderPaths.map((path) => this.libraryRepo.insertFolder({ libraryId: id, path }))); + } + + const folders = await this.libraryRepo.findFoldersByLibrary(id); + return { ...updated, folders }; + } + + async remove(id: number) { + const [existing] = await this.libraryRepo.findById(id); + if (!existing) throw new NotFoundException('Library not found'); + await this.libraryRepo.delete(id); + } + + async prescan(dto: PrescanLibraryDto) { + const allFolderPaths = await this.libraryRepo.findAllFolderPaths(); + + const results = await Promise.all( + dto.paths.map(async (inputPath) => { + let accessible = false; + let fileCount = 0; + let overlapLibrary: string | undefined; + + try { + await access(inputPath); + const s = await stat(inputPath); + if (!s.isDirectory()) { + return { path: inputPath, accessible: false, fileCount: 0, error: 'Not a directory' }; + } + accessible = true; + fileCount = await countPrimaryFiles(inputPath); + } catch { + accessible = false; + } + + // Check if this path overlaps an existing library folder + for (const existing of allFolderPaths) { + if (pathsOverlap(inputPath, existing.path)) { + overlapLibrary = existing.libraryName; + break; + } + } + + return { path: inputPath, accessible, fileCount, overlapLibrary }; + }), + ); + + const totalFiles = results.reduce((sum, r) => sum + r.fileCount, 0); + return { paths: results, totalFiles }; + } + + async getStats(libraryId: number) { + const [existing] = await this.libraryRepo.findById(libraryId); + if (!existing) throw new NotFoundException('Library not found'); + return this.libraryRepo.getStats(libraryId); + } + + async reorder(dto: ReorderLibrariesDto) { + await this.libraryRepo.updateDisplayOrders(dto.order); + } + getAccess(libraryId: number) { - return this.libraryRepo.getAccess(libraryId); + return this.libraryRepo.getAccessWithUsers(libraryId); } grantAccess(libraryId: number, dto: GrantLibraryAccessDto) { @@ -34,4 +146,37 @@ export class LibraryService { revokeAccess(libraryId: number, userId: number) { return this.libraryRepo.revokeAccess(libraryId, userId); } + + private async assertNameAvailable(name: string, excludeId?: number) { + const existing = await this.libraryRepo.findByName(name, excludeId); + if (existing.length > 0) throw new ConflictException('A library with this name already exists'); + } +} + +// Count primary-format files recursively in a directory (non-throwing) +async function countPrimaryFiles(dir: string): Promise { + let count = 0; + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + count += await countPrimaryFiles(full); + } else if (entry.isFile() && isPrimaryFormat(full)) { + count++; + } + } + } catch { + // Unreadable sub-directory — skip + } + return count; +} + +// True if one path is a prefix of the other (i.e. they overlap in the file tree) +function pathsOverlap(a: string, b: string): boolean { + const normalize = (p: string) => (p.endsWith('/') ? p : p + '/'); + const na = normalize(a); + const nb = normalize(b); + return na.startsWith(nb) || nb.startsWith(na); } diff --git a/server/src/modules/metadata/metadata.service.ts b/server/src/modules/metadata/metadata.service.ts index ee2f1f651..03616865e 100644 --- a/server/src/modules/metadata/metadata.service.ts +++ b/server/src/modules/metadata/metadata.service.ts @@ -197,10 +197,13 @@ export class MetadataService { private async saveAuthors(bookId: number, parsedAuthors: { name: string; sortName: string | null }[]) { if (parsedAuthors.length === 0) return; + // Deduplicate by name to avoid inserting the same author twice for one book + const unique = parsedAuthors.filter((a, i, arr) => arr.findIndex((b) => b.name === a.name) === i); + await this.db.delete(bookAuthors).where(eq(bookAuthors.bookId, bookId)); - for (let i = 0; i < parsedAuthors.length; i++) { - const { name, sortName } = parsedAuthors[i]!; + for (let i = 0; i < unique.length; i++) { + const { name, sortName } = unique[i]!; let [author] = await this.db.select().from(authors).where(eq(authors.name, name)).limit(1); @@ -208,7 +211,7 @@ export class MetadataService { [author] = await this.db.insert(authors).values({ name, sortName }).returning(); } - await this.db.insert(bookAuthors).values({ bookId, authorId: author!.id, displayOrder: i }); + await this.db.insert(bookAuthors).values({ bookId, authorId: author!.id, displayOrder: i }).onConflictDoNothing(); } } @@ -227,7 +230,7 @@ export class MetadataService { [tag] = await this.db.insert(tags).values({ name }).returning(); } - await this.db.insert(bookTags).values({ bookId, tagId: tag!.id }); + await this.db.insert(bookTags).values({ bookId, tagId: tag!.id }).onConflictDoNothing(); } } } diff --git a/server/src/modules/path/path.controller.ts b/server/src/modules/path/path.controller.ts new file mode 100644 index 000000000..bb73fb754 --- /dev/null +++ b/server/src/modules/path/path.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Query } from '@nestjs/common'; + +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; +import { PathService } from './path.service'; + +@Controller('path') +export class PathController { + constructor(private readonly pathService: PathService) {} + + @Get() + @RequirePermission('manage_libraries') + listDirectories(@Query('path') path: string) { + return this.pathService.listDirectories(path || '/'); + } +} diff --git a/server/src/modules/path/path.module.ts b/server/src/modules/path/path.module.ts new file mode 100644 index 000000000..f260a8114 --- /dev/null +++ b/server/src/modules/path/path.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { PathController } from './path.controller'; +import { PathService } from './path.service'; + +@Module({ + controllers: [PathController], + providers: [PathService], +}) +export class PathModule {} diff --git a/server/src/modules/path/path.service.ts b/server/src/modules/path/path.service.ts new file mode 100644 index 000000000..70451df6d --- /dev/null +++ b/server/src/modules/path/path.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { readdir, stat } from 'fs/promises'; +import { join, resolve, sep } from 'path'; + +const BLOCKED = ['/proc', '/sys', '/dev', '/run', '/var/run']; + +@Injectable() +export class PathService { + async listDirectories(rawPath: string): Promise<{ name: string; path: string }[]> { + const resolved = resolve(rawPath || '/'); + if (BLOCKED.some((b) => resolved === b || resolved.startsWith(b + sep))) { + return []; + } + try { + const entries = await readdir(resolved, { withFileTypes: true }); + const dirs: { name: string; path: string }[] = []; + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + if (entry.name.startsWith('.')) continue; + const full = join(resolved, entry.name); + try { + const s = await stat(full); + if (s.isDirectory()) dirs.push({ name: entry.name, path: full }); + } catch { + // skip inaccessible entries + } + } + return dirs.sort((a, b) => a.name.localeCompare(b.name)); + } catch { + return []; + } + } +}