feat(library): add creator wizard, folder picker, and full CRUD

This commit is contained in:
neon
2026-01-14 13:13:07 -06:00
parent 8dea90d966
commit 760cc6476f
28 changed files with 2346 additions and 53 deletions
+2
View File
@@ -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,
@@ -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)",
+2 -2
View File
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1768382694329,
"tag": "0000_add_oidc_schema",
"when": 1768464558856,
"tag": "0000_baseline",
"breakpoints": true
}
]
+23 -1
View File
@@ -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));
}
}
+147 -2
View File
@@ -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 || '/');
}
}
+10
View File
@@ -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 {}
+33
View File
@@ -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 [];
}
}
}