Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions backend/migrations/1708600000000-AddLibrarySources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddLibrarySources1708600000000 implements MigrationInterface {
name = 'AddLibrarySources1708600000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "library_sources" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"name" varchar(300) NOT NULL,
"polling_enabled" boolean NOT NULL DEFAULT true,
"polling_interval_seconds" integer NOT NULL DEFAULT 300,
"last_scanned_at" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
)
`);

await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "library_source_paths" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"library_source_id" uuid NOT NULL REFERENCES "library_sources"("id") ON DELETE CASCADE,
"path" varchar(1000) NOT NULL,
"last_scanned_at" timestamptz,
"file_count" integer NOT NULL DEFAULT 0,
"created_at" timestamptz NOT NULL DEFAULT now()
)
`);

await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_library_source_paths_source"
ON "library_source_paths" ("library_source_id")
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "library_source_paths"`);
await queryRunner.query(`DROP TABLE IF EXISTS "library_sources"`);
}
}
21 changes: 21 additions & 0 deletions backend/migrations/1709000000000-AddCoverImageBlob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddCoverImageBlob1709000000000 implements MigrationInterface {
name = 'AddCoverImageBlob1709000000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "book_metadata"
ADD COLUMN IF NOT EXISTS "cover_image" bytea,
ADD COLUMN IF NOT EXISTS "cover_image_type" varchar(50)
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "book_metadata"
DROP COLUMN IF EXISTS "cover_image",
DROP COLUMN IF EXISTS "cover_image_type"
`);
}
}
8 changes: 7 additions & 1 deletion backend/src/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ import { ReadingGoalEntity } from './entities/ReadingGoal';
import { ReadingGoalCompletedDayEntity } from './entities/ReadingGoalCompletedDay';
import { ShelfEntity } from './entities/Shelf';
import { DocumentFileEntity } from './entities/DocumentFile';
import { LibrarySourceEntity } from './entities/LibrarySource';
import { LibrarySourcePathEntity } from './entities/LibrarySourcePath';
import { InitialSchema1700000000000 } from '../migrations/1700000000000-InitialSchema';
import { AddBookmarkChapter1708523246000 } from '../migrations/1708523246000-AddBookmarkChapter';
import { AddLibrarySources1708600000000 } from '../migrations/1708600000000-AddLibrarySources';
import { AddCoverImageBlob1709000000000 } from '../migrations/1709000000000-AddCoverImageBlob';

dotenv.config();

Expand All @@ -36,7 +40,9 @@ export const AppDataSource = new DataSource({
ReadingGoalCompletedDayEntity,
ShelfEntity,
DocumentFileEntity,
LibrarySourceEntity,
LibrarySourcePathEntity,
],
migrations: [InitialSchema1700000000000, AddBookmarkChapter1708523246000],
migrations: [InitialSchema1700000000000, AddBookmarkChapter1708523246000, AddLibrarySources1708600000000, AddCoverImageBlob1709000000000],
subscribers: [],
});
6 changes: 6 additions & 0 deletions backend/src/entities/BookMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ export class BookMetadataEntity {
@Column({ name: 'cover_url', type: 'text', nullable: true })
coverUrl: string | null;

@Column({ name: 'cover_image', type: 'bytea', nullable: true })
coverImage: Buffer | null;

@Column({ name: 'cover_image_type', type: 'varchar', length: 50, nullable: true })
coverImageType: string | null;

@Column({ type: 'text', nullable: true })
description: string | null;

Expand Down
39 changes: 39 additions & 0 deletions backend/src/entities/LibrarySource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { LibrarySourcePathEntity } from './LibrarySourcePath';

@Entity('library_sources')
export class LibrarySourceEntity {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ type: 'varchar', length: 300 })
name: string;

@Column({ name: 'polling_enabled', type: 'boolean', default: true })
pollingEnabled: boolean;

@Column({ name: 'polling_interval_seconds', type: 'int', default: 300 })
pollingIntervalSeconds: number;

@Column({ name: 'last_scanned_at', type: 'timestamptz', nullable: true })
lastScannedAt: Date | null;

@OneToMany(() => LibrarySourcePathEntity, (p) => p.librarySource, {
cascade: true,
eager: true,
})
paths: LibrarySourcePathEntity[];

@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;

@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}
35 changes: 35 additions & 0 deletions backend/src/entities/LibrarySourcePath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { LibrarySourceEntity } from './LibrarySource';

@Entity('library_source_paths')
export class LibrarySourcePathEntity {
@PrimaryGeneratedColumn('uuid')
id: string;

/** Absolute directory path on the host filesystem */
@Column({ type: 'varchar', length: 1000 })
path: string;

@Column({ name: 'last_scanned_at', type: 'timestamptz', nullable: true })
lastScannedAt: Date | null;

@Column({ name: 'file_count', type: 'int', default: 0 })
fileCount: number;

@Column({ name: 'library_source_id', type: 'uuid' })
librarySourceId: string;

@ManyToOne(() => LibrarySourceEntity, (s) => s.paths, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'library_source_id' })
librarySource: LibrarySourceEntity;

@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}
2 changes: 2 additions & 0 deletions backend/src/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ export { ReadingGoalEntity } from './ReadingGoal';
export { ReadingGoalCompletedDayEntity } from './ReadingGoalCompletedDay';
export { ShelfEntity } from './Shelf';
export { DocumentFileEntity } from './DocumentFile';
export { LibrarySourceEntity } from './LibrarySource';
export { LibrarySourcePathEntity } from './LibrarySourcePath';
5 changes: 4 additions & 1 deletion backend/src/helpers/dto-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ export function toDocumentDTO(entity: DocumentEntity): any {
publisher: entity.metadata.publisher ?? undefined,
publishYear: entity.metadata.publishYear ?? undefined,
isbn: entity.metadata.isbn ?? undefined,
coverUrl: entity.metadata.coverUrl ?? undefined,
// Prefer locally stored cover blob; fall back to external URL
coverUrl: entity.metadata.coverImage
? `/api/documents/${entity.id}/cover`
: (entity.metadata.coverUrl ?? undefined),
description: entity.metadata.description ?? undefined,
pageCount: entity.metadata.pageCount ?? undefined,
subjects: (entity.metadata.subjects ?? []).map((s: any) => s.name),
Expand Down
135 changes: 135 additions & 0 deletions backend/src/routes/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Router, Request, Response, NextFunction } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import https from 'https';
import http from 'http';
import { AppDataSource } from '../data-source';
import {
DocumentEntity,
Expand Down Expand Up @@ -47,6 +49,50 @@ const upload = multer({
limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB
});

// Multer instance for cover image uploads
const coverUpload = multer({
storage: multer.memoryStorage(),
fileFilter: (_req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed for covers'));
}
},
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
});

/**
* Download an image from a URL and return the buffer + content type.
*/
function downloadImage(url: string): Promise<{ buffer: Buffer; contentType: string }> {
return new Promise((resolve, reject) => {
const client = url.startsWith('https') ? https : http;
const request = client.get(url, { timeout: 10000 }, (response) => {
// Follow redirects
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
return downloadImage(response.headers.location).then(resolve).catch(reject);
}
if (response.statusCode !== 200) {
return reject(new Error(`HTTP ${response.statusCode}`));
}
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () => {
const buffer = Buffer.concat(chunks);
const contentType = response.headers['content-type'] || 'image/jpeg';
resolve({ buffer, contentType });
});
response.on('error', reject);
});
request.on('error', reject);
request.on('timeout', () => {
request.destroy();
reject(new Error('Download timeout'));
});
});
}

// Helper: get document repo with full relations
function getDocRepo() {
return AppDataSource.getRepository(DocumentEntity);
Expand Down Expand Up @@ -191,6 +237,21 @@ router.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
if (metadata.publishYear !== undefined) metaEntity.publishYear = metadata.publishYear;
if (metadata.isbn !== undefined) metaEntity.isbn = metadata.isbn;
if (metadata.coverUrl !== undefined) metaEntity.coverUrl = metadata.coverUrl;

// If coverUrl is an external URL, download and store as blob
if (metadata.coverUrl && /^https?:\/\//.test(metadata.coverUrl)) {
try {
const { buffer, contentType } = await downloadImage(metadata.coverUrl);
metaEntity.coverImage = buffer;
metaEntity.coverImageType = contentType || 'image/jpeg';
// Clear the external URL since we now have the blob
metaEntity.coverUrl = null;
} catch (downloadErr) {
logger.warn(`Failed to download cover image from ${metadata.coverUrl}:`, downloadErr);
// Keep the external URL as fallback
}
}

if (metadata.description !== undefined) metaEntity.description = metadata.description;
if (metadata.pageCount !== undefined) metaEntity.pageCount = metadata.pageCount;
if (metadata.openLibraryKey !== undefined) metaEntity.openLibraryKey = metadata.openLibraryKey;
Expand Down Expand Up @@ -242,6 +303,80 @@ router.delete('/:id', async (req: Request, res: Response, next: NextFunction) =>
}
});

// ===== COVER IMAGE =====

/** GET /api/documents/:id/cover — serve stored cover image */
router.get('/:id/cover', async (req: Request, res: Response, next: NextFunction) => {
try {
const metaRepo = AppDataSource.getRepository(BookMetadataEntity);
const meta = await metaRepo.findOne({ where: { documentId: req.params.id } });
if (!meta || !meta.coverImage) {
return res.status(404).json({ error: { message: 'No cover image stored' } });
}

res.setHeader('Content-Type', meta.coverImageType || 'image/jpeg');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.send(meta.coverImage);
} catch (err) {
next(err);
}
});

/** PUT /api/documents/:id/cover — upload a cover image directly */
router.put('/:id/cover', coverUpload.single('cover'), async (req: Request, res: Response, next: NextFunction) => {
try {
const docRepo = getDocRepo();
const doc = await docRepo.findOne({ where: { id: req.params.id } });
if (!doc) return res.status(404).json({ error: { message: 'Document not found' } });

const metaRepo = AppDataSource.getRepository(BookMetadataEntity);
let meta = await metaRepo.findOne({ where: { documentId: req.params.id } });
if (!meta) {
meta = metaRepo.create({ documentId: req.params.id });
}

if (req.file) {
// Direct file upload
meta.coverImage = req.file.buffer;
meta.coverImageType = req.file.mimetype;
meta.coverUrl = null; // Clear external URL
} else if (req.body.coverUrl && /^https?:\/\//.test(req.body.coverUrl)) {
// URL provided — download and store
const { buffer, contentType } = await downloadImage(req.body.coverUrl);
meta.coverImage = buffer;
meta.coverImageType = contentType;
meta.coverUrl = null;
} else {
return res.status(400).json({ error: { message: 'Provide a cover file or coverUrl' } });
}

await metaRepo.save(meta);

const fullDoc = await loadFullDocument(req.params.id);
res.json(toDocumentDTO(fullDoc!));
} catch (err) {
next(err);
}
});

/** DELETE /api/documents/:id/cover — remove stored cover image */
router.delete('/:id/cover', async (req: Request, res: Response, next: NextFunction) => {
try {
const metaRepo = AppDataSource.getRepository(BookMetadataEntity);
const meta = await metaRepo.findOne({ where: { documentId: req.params.id } });
if (!meta) return res.status(404).json({ error: { message: 'Metadata not found' } });

meta.coverImage = null;
meta.coverImageType = null;
meta.coverUrl = null;
await metaRepo.save(meta);

res.status(204).send();
} catch (err) {
next(err);
}
});

/** GET /api/documents/:id/file — download/stream document file */
router.get('/:id/file', async (req: Request, res: Response, next: NextFunction) => {
try {
Expand Down
Loading
Loading