mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(library): add creator wizard, folder picker, and full CRUD
This commit is contained in:
@@ -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,
|
||||
|
||||
+10
@@ -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,
|
||||
@@ -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)",
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1768382694329,
|
||||
"tag": "0000_add_oidc_schema",
|
||||
"when": 1768464558856,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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<string[]>()
|
||||
.notNull()
|
||||
.default(['folderStructure', 'embedded', 'nfoFile', 'opfFile', 'sidecar']),
|
||||
formatPriority: jsonb('format_priority').$type<string[]>().notNull().default(['epub', 'pdf', 'cbz', 'cbr', 'mobi', 'azw3', 'fb2']),
|
||||
allowedFormats: jsonb('allowed_formats').$type<string[]>().notNull().default([]),
|
||||
organizationMode: varchar('organization_mode', { length: 20 }).notNull().default('auto'),
|
||||
excludePatterns: jsonb('exclude_patterns').$type<string[]>().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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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<typeof schema>;
|
||||
|
||||
@@ -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<typeof libraries.$inferInsert>) {
|
||||
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<number>`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<number>`count(*)::int`,
|
||||
totalSize: sql<number>`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<string, number> = {};
|
||||
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<boolean> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || '/');
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user