Added modules for books & series.

This commit is contained in:
Tom
2025-02-24 20:54:58 +00:00
parent 8f0ca1ce58
commit a44cd89072
25 changed files with 767 additions and 41 deletions

View File

@ -12,7 +12,9 @@ import { UserEntity } from './users/entities/users.entity';
import { AuthModule } from './auth/auth.module';
import { LoggerModule } from 'nestjs-pino';
import { serialize_token, serialize_user_short, serialize_user_long, serialize_res, serialize_req } from './logging.serializers';
import { BooksModule } from './books/books.module';
import { ProvidersModule } from './providers/providers.module';
import { SeriesModule } from './series/series.module';
@Module({
imports: [
@ -54,7 +56,9 @@ import { ProvidersModule } from './providers/providers.module';
}
}
}),
ProvidersModule
BooksModule,
ProvidersModule,
SeriesModule
],
controllers: [AppController],
providers: [AppService, UsersService],

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BooksController } from './books.controller';
describe('BooksController', () => {
let controller: BooksController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [BooksController],
}).compile();
controller = module.get<BooksController>(BooksController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,94 @@
import { Body, Controller, Get, Post, Put, Request, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
import { BooksService } from './books.service';
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
import { UpdateBookOriginDto } from './dto/update-book-origin.dto';
import { UpdateBookDto } from './dto/update-book.dto';
import { PinoLogger } from 'nestjs-pino';
import { QueryFailedError } from 'typeorm';
@UseGuards(JwtAccessGuard)
@Controller('books')
export class BooksController {
constructor(
private books: BooksService,
private logger: PinoLogger,
) { }
@Post('')
async CreateBook(
@Request() req,
@Body() body: BookSearchResultDto,
@Res({ passthrough: true }) response: Response,
) {
try {
return {
success: true,
data: await this.books.createBook(body),
};
} catch (err) {
if (err instanceof QueryFailedError) {
if (err.driverError.code == '23505') {
// Book exists already.
response.statusCode = 400;
return {
success: false,
error_message: 'The book has already been added previously.',
}
}
}
this.logger.error({
class: BooksController.name,
method: this.CreateBook.name,
user: req.user,
msg: 'Failed to create book.',
error: err,
});
response.statusCode = 500;
return {
success: false,
error_message: 'Something went wrong while adding the book.',
}
}
}
@Get('')
async GetBooksFromUser(
@Request() req,
) {
return {
success: true,
data: await this.books.findBookStatusesTrackedBy(req.user.userId),
};
}
@Put('')
async UpdateBook(
@Body() body: UpdateBookDto,
) {
const data = { ...body };
delete data['bookId'];
const result = await this.books.updateBook(body.bookId, data);
return {
success: result?.affected == 1,
};
}
@Put('origins')
async UpdateBookOrigin(
@Body() body: UpdateBookOriginDto,
) {
const data = { ...body };
delete data['bookOriginId'];
const result = await this.books.updateBookOrigin(body.bookOriginId, data);
return {
success: result?.affected == 1,
};
}
}

View File

@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { BooksController } from './books.controller';
import { BooksService } from './books.service';
import { BookEntity } from './entities/book.entity';
import { BookOriginEntity } from './entities/book-origin.entity';
import { BookStatusEntity } from './entities/book-status.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HttpModule } from '@nestjs/axios';
import { ProvidersModule } from 'src/providers/providers.module';
import { SeriesModule } from 'src/series/series.module';
@Module({
imports: [
TypeOrmModule.forFeature([
BookEntity,
BookOriginEntity,
BookStatusEntity,
]),
SeriesModule,
HttpModule,
ProvidersModule,
],
controllers: [BooksController],
exports: [
BooksService
],
providers: [BooksService]
})
export class BooksModule {}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BooksService } from './books.service';
describe('BooksService', () => {
let service: BooksService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [BooksService],
}).compile();
service = module.get<BooksService>(BooksService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,169 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BookEntity } from './entities/book.entity';
import { In, InsertResult, Repository } from 'typeorm';
import { BookOriginEntity } from './entities/book-origin.entity';
import { BookStatusEntity } from './entities/book-status.entity';
import { UUID } from 'crypto';
import { PinoLogger } from 'nestjs-pino';
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
import { BookOriginType } from 'src/shared/enums/book_origin_type';
import { CreateBookDto } from './dto/create-book.dto';
import { CreateBookOriginDto } from './dto/create-book-origin.dto';
import { CreateBookStatusDto as BookStatusDto } from './dto/book-status.dto';
import { SeriesService } from 'src/series/series.service';
@Injectable()
export class BooksService {
constructor(
@InjectRepository(BookEntity)
private bookRepository: Repository<BookEntity>,
@InjectRepository(BookOriginEntity)
private bookOriginRepository: Repository<BookOriginEntity>,
@InjectRepository(BookStatusEntity)
private bookStatusRepository: Repository<BookStatusEntity>,
private series: SeriesService,
private logger: PinoLogger,
) { }
async createBook(book: BookSearchResultDto) {
this.logger.debug({
class: BooksService.name,
method: this.createBook.name,
book: book,
msg: 'Saving book to database...',
});
if (book.providerSeriesId) {
await this.series.updateSeries({
providerSeriesId: book.providerSeriesId,
title: book.title,
provider: book.provider,
});
this.logger.debug({
class: BooksService.name,
method: this.createBook.name,
series: book.providerSeriesId,
msg: 'Series saved to database.',
});
}
const createBook: CreateBookDto = {
title: book.title,
desc: book.desc,
providerSeriesId: book.providerSeriesId,
providerBookId: book.providerBookId,
volume: book.volume,
provider: book.provider,
publishedAt: book.publishedAt,
};
const data = await this.createBookInternal(createBook);
const bookId = data.identifiers[0]['bookId'];
const tasks = [];
tasks.push(book.authors.map(author => this.addBookOrigin(bookId, BookOriginType.AUTHOR, author)));
tasks.push(book.categories.map(category => this.addBookOrigin(bookId, BookOriginType.CATEGORY, category)));
tasks.push(this.addBookOrigin(bookId, BookOriginType.LANGUAGE, book.language));
if (book.maturityRating) {
tasks.push(this.addBookOrigin(bookId, BookOriginType.MATURITY_RATING, book.maturityRating));
}
if (book.thumbnail) {
tasks.push(this.addBookOrigin(bookId, BookOriginType.PROVIDER_THUMBNAIL, book.thumbnail));
}
if (book.url) {
tasks.push(this.addBookOrigin(bookId, BookOriginType.PROVIDER_URL, book.url));
}
if ('ISBN_10' in book.industryIdentifiers) {
tasks.push(this.addBookOrigin(bookId, BookOriginType.ISBN_10, book.industryIdentifiers['ISBN_10']));
}
if ('ISBN_13' in book.industryIdentifiers) {
tasks.push(this.addBookOrigin(bookId, BookOriginType.ISBN_10, book.industryIdentifiers['ISBN_13']));
}
await Promise.all(tasks);
this.logger.info({
class: BooksService.name,
method: this.createBook.name,
book: book,
msg: 'Book saved to database.',
});
return bookId;
}
private async createBookInternal(book: CreateBookDto): Promise<InsertResult> {
const entity = this.bookRepository.create(book);
return await this.bookRepository.createQueryBuilder()
.insert()
.into(BookEntity)
.values(entity)
.returning('book_id')
.execute();
}
async addBookOrigin(bookId: UUID, type: BookOriginType, value: string): Promise<InsertResult> {
return await this.bookOriginRepository.insert({
bookId,
type,
value,
});
}
async findBooksByIds(bookIds: UUID[]) {
return await this.bookRepository.find({
where: {
bookId: In(bookIds)
}
})
}
async findBookStatusesTrackedBy(userId: UUID): Promise<BookStatusEntity[]> {
return await this.bookStatusRepository.createQueryBuilder('s')
.select(['s.book_id', 's.user_id'])
.where('s.user_id = :id', { id: userId })
.innerJoin('s.book', 'b')
.addSelect(['b.book_title', 'b.book_desc', 'b.book_volume', 'b.provider'])
.getMany();
}
async findSeriesTrackedBy(userId: UUID) {
return await this.bookStatusRepository.createQueryBuilder('s')
.where({
whereFactory: {
userId: userId
}
})
.innerJoin('s.book', 'b')
.addSelect(['b.provider', 'b.providerSeriesId'])
.distinctOn(['b.provider', 'b.providerSeriesId'])
.getMany();
}
async updateBook(bookId: UUID, update: CreateBookDto) {
return await this.bookRepository.update({
bookId,
}, update);
}
async updateBookOrigin(bookOriginId: UUID, update: CreateBookOriginDto) {
return await this.bookOriginRepository.update({
bookOriginId
}, update);
}
async updateBookStatus(status: BookStatusDto) {
status.modifiedAt = new Date();
await this.bookStatusRepository.createQueryBuilder()
.insert()
.values(status)
.orUpdate(['user_id', 'book_id'], ['state', 'modified_at'], { skipUpdateIfNoValuesChanged: true })
.execute();
return await this.bookStatusRepository.upsert(status, ['book_id']);
}
}

View File

@ -0,0 +1,19 @@
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { UUID } from 'crypto';
export class CreateBookStatusDto {
@IsUUID()
@IsNotEmpty()
readonly bookId: UUID;
@IsUUID()
@IsNotEmpty()
@IsOptional()
readonly userId: UUID;
@IsString()
@IsNotEmpty()
state: string;
modifiedAt: Date;
}

View File

@ -0,0 +1,19 @@
import { Transform } from 'class-transformer';
import { IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator';
import { UUID } from 'crypto';
import { BookOriginType } from 'src/shared/enums/book_origin_type';
export class CreateBookOriginDto {
@IsUUID()
@IsNotEmpty()
readonly bookId: UUID;
@IsNumber()
@IsNotEmpty()
@Transform(({ value }) => value as BookOriginType)
type: BookOriginType;
@IsString()
@IsNotEmpty()
value: string;
}

View File

@ -0,0 +1,35 @@
import { Transform } from 'class-transformer';
import { IsDate, IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString, MaxLength } from 'class-validator';
export class CreateBookDto {
@IsString()
@IsOptional()
providerSeriesId: string;
@IsString()
@IsNotEmpty()
providerBookId: string;
@IsString()
@IsNotEmpty()
@MaxLength(128)
title: string;
@IsString()
@MaxLength(512)
desc: string;
@IsNumber()
@IsOptional()
@IsPositive()
volume: number | null;
@IsString()
@IsNotEmpty()
provider: string;
@IsDate()
@IsNotEmpty()
@Transform(({ value }) => new Date(value))
publishedAt: Date
}

View File

@ -0,0 +1,23 @@
import { Transform } from 'class-transformer';
import { IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator';
import { UUID } from 'crypto';
import { BookOriginType } from 'src/shared/enums/book_origin_type';
export class UpdateBookOriginDto {
@IsUUID()
@IsNotEmpty()
readonly bookOriginId: UUID;
@IsUUID()
@IsNotEmpty()
readonly bookId: UUID;
@IsNumber()
@IsNotEmpty()
@Transform(({ value }) => value as BookOriginType)
type: BookOriginType;
@IsString()
@IsNotEmpty()
value: string;
}

View File

@ -0,0 +1,40 @@
import { Transform } from 'class-transformer';
import { IsDate, IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString, IsUUID, MaxLength } from 'class-validator';
import { UUID } from 'crypto';
export class UpdateBookDto {
@IsUUID()
@IsNotEmpty()
readonly bookId: UUID;
@IsString()
@IsOptional()
providerSeriesId: string;
@IsString()
@IsNotEmpty()
providerBookId: string;
@IsString()
@IsNotEmpty()
@MaxLength(128)
title: string;
@IsString()
@MaxLength(512)
desc: string;
@IsNumber()
@IsOptional()
@IsPositive()
volume: number | null;
@IsString()
@IsNotEmpty()
provider: string;
@IsDate()
@IsNotEmpty()
@Transform(({ value }) => new Date(value))
publishedAt: Date
}

View File

@ -0,0 +1,24 @@
import { UUID } from 'crypto';
import { BookOriginType } from 'src/shared/enums/book_origin_type';
import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryColumn, Unique } from 'typeorm';
import { BookEntity } from './book.entity';
@Entity("book_origins")
@Unique(['bookOriginId', 'type', 'value'])
export class BookOriginEntity {
@PrimaryColumn({ name: 'book_origin_id' })
readonly bookOriginId: UUID;
@Column({ name: 'book_id' })
readonly bookId: UUID;
@Column({ name: 'origin_type' })
type: BookOriginType;
@Column({ name: 'origin_value' })
value: string;
@OneToOne(type => BookEntity, book => book.metadata)
@JoinColumn({ name: 'book_id' })
book: BookEntity;
}

View File

@ -0,0 +1,30 @@
import { UUID } from 'crypto';
import { UserEntity } from 'src/users/entities/users.entity';
import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryColumn } from 'typeorm';
import { BookEntity } from './book.entity';
@Entity("book_statuses")
export class BookStatusEntity {
@PrimaryColumn({ name: 'book_id', type: 'uuid' })
readonly bookId: UUID;
@PrimaryColumn({ name: 'user_id', type: 'uuid' })
readonly userId: UUID;
@Column({ name: 'state', type: 'varchar' })
state: string;
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
addedAt: Date
@Column({ name: 'modified_at', type: 'timestamptz', nullable: false })
modifiedAt: Date;
@OneToOne(type => BookEntity, book => book.statuses)
@JoinColumn({ name: 'book_id' })
book: BookEntity;
@OneToOne(type => UserEntity, user => user.bookStatuses)
@JoinColumn({ name: 'user_id' })
user: UserEntity;
}

View File

@ -0,0 +1,47 @@
import { UUID } from 'crypto';
import { Column, Entity, JoinColumn, OneToMany, OneToOne, PrimaryColumn, Unique } from 'typeorm';
import { BookOriginEntity } from './book-origin.entity';
import { BookStatusEntity } from './book-status.entity';
import { SeriesEntity } from 'src/series/entities/series.entity';
@Entity("books")
@Unique(['providerSeriesId', 'providerBookId'])
export class BookEntity {
@PrimaryColumn({ name: 'book_id', type: 'uuid' })
readonly bookId: UUID;
@Column({ name: 'provider_series_id', type: 'text', nullable: true })
providerSeriesId: string;
@Column({ name: 'provider_book_id', type: 'text', nullable: false })
providerBookId: string;
@Column({ name: 'book_title', type: 'text', nullable: false })
title: string;
@Column({ name: 'book_desc', type: 'text', nullable: true })
desc: string;
@Column({ name: 'book_volume', type: 'integer', nullable: true })
volume: number;
@Column({ name: 'provider', type: 'varchar', nullable: false })
provider: string;
@Column({ name: 'published_at', type: 'timestamptz', nullable: false })
publishedAt: Date
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
addedAt: Date;
@OneToMany(type => BookOriginEntity, origin => origin.bookId)
metadata: BookOriginEntity[];
@OneToMany(type => BookStatusEntity, status => status.bookId)
statuses: BookStatusEntity[];
@OneToOne(type => SeriesEntity, series => series.volumes)
@JoinColumn({ name: 'provider_series_id' })
@JoinColumn({ name: 'provider' })
series: SeriesEntity;
}

View File

@ -17,7 +17,7 @@ export class GoogleService {
this.http.get('https://www.googleapis.com/books/v1/volumes?' + queryParams + searchQuery)
.pipe(
timeout({ first: 5000 }),
map(this.transform),
map(value => this.transform(value)),
)
);
}
@ -40,8 +40,10 @@ export class GoogleService {
return [];
}
const items: any[] = response.data.items;
return items.map((item: any) => {
return response.data.items.map(item => this.extract(item));
}
private extract(item: any): BookSearchResultDto {
const result: BookSearchResultDto = {
providerBookId: item.id,
providerSeriesId: item.volumeInfo.seriesInfo?.volumeSeries[0].seriesId,
@ -55,21 +57,13 @@ export class GoogleService {
industryIdentifiers: Object.assign({}, ...item.volumeInfo.industryIdentifiers.map(i => ({ [i.type]: i.identifier }))),
publishedAt: new Date(item.volumeInfo.publishedDate),
language: item.volumeInfo.language,
thumbnail: item.volumeInfo.imageLinks.thumbnail,
thumbnail: item.volumeInfo.imageLinks?.thumbnail,
url: item.volumeInfo.canonicalVolumeLink,
provider: 'google'
}
if (result.providerSeriesId) {
let regex = null;
switch (result.publisher) {
case 'J-Novel Club':
regex = new RegExp(/(?<title>.+?):?\sVolume\s(?<volume>\d+)/);
case 'Yen Press LLC':
regex = new RegExp(/(?<title>.+?),?\sVol\.\s(?<volume>\d+)\s\((?<media_type>\w+)\)/);
default:
regex = new RegExp(/(?<title>.+?)(?:,|:|\s\-)?\s(?:Vol(?:\.|ume)?)?\s(?<volume>\d+)/);
}
let regex = this.getRegexByPublisher(result.publisher);
const match = result.title.match(regex);
if (match?.groups) {
@ -81,6 +75,18 @@ export class GoogleService {
}
return result;
});
}
private getRegexByPublisher(publisher: string): RegExp {
switch (publisher) {
case 'J-Novel Club':
return /(?<title>.+?):?\sVolume\s(?<volume>\d+)/i;
case 'Yen On':
case 'Yen Press':
case 'Yen Press LLC':
return /(?<title>.+?),?\sVol\.\s(?<volume>\d+)\s\((?<media_type>[\w\s]+)\)/;
default:
return /(?<title>.+?)(?:,|:|\s\-)?\s(?:Vol(?:\.|ume)?)?\s(?<volume>\d+)/;
}
}
}

View File

@ -4,7 +4,7 @@ import { ProvidersService } from './providers.service';
import { HttpModule } from '@nestjs/axios';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ProvidersController } from './providers.controller';
import { BooksService } from 'src/books/books/books.service';
import { BooksService } from 'src/books/books.service';
@Module({
imports: [

View File

@ -0,0 +1,15 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateSeriesDto {
@IsString()
@IsNotEmpty()
providerSeriesId: string;
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
provider: string;
}

View File

@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class DeleteSeriesDto {
@IsString()
@IsNotEmpty()
providerSeriesId: string;
@IsString()
@IsNotEmpty()
provider: string;
}

View File

@ -0,0 +1,20 @@
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { UUID } from 'crypto';
export class UpdateSeriesDto {
@IsUUID()
@IsNotEmpty()
seriesId: UUID;
@IsString()
@IsNotEmpty()
providerSeriesId: string;
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
provider: string;
}

View File

@ -0,0 +1,25 @@
import { UUID } from 'crypto';
import { BookEntity } from 'src/books/entities/book.entity';
import { Column, Entity, OneToMany, PrimaryColumn, Unique } from 'typeorm';
@Entity("series")
@Unique(['provider', 'providerSeriesId'])
export class SeriesEntity {
@PrimaryColumn({ name: 'series_id', type: 'uuid' })
readonly seriesId: UUID;
@Column({ name: 'provider_series_id', type: 'text', nullable: true })
providerSeriesId: string;
@Column({ name: 'series_title', type: 'text', nullable: false })
title: string;
@Column({ name: 'provider', type: 'text', nullable: false })
provider: string;
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
addedAt: Date;
@OneToMany(type => BookEntity, book => [book.provider, book.providerSeriesId])
volumes: BookEntity[];
}

View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { SeriesService } from './series.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SeriesEntity } from './entities/series.entity';
import { HttpModule } from '@nestjs/axios';
import { ProvidersModule } from 'src/providers/providers.module';
@Module({
imports: [
TypeOrmModule.forFeature([
SeriesEntity,
]),
HttpModule,
ProvidersModule,
],
controllers: [],
exports: [
SeriesService,
],
providers: [SeriesService]
})
export class SeriesModule { }

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SeriesService } from './series.service';
describe('SeriesService', () => {
let service: SeriesService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SeriesService],
}).compile();
service = module.get<SeriesService>(SeriesService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { SeriesEntity } from './entities/series.entity';
import { Repository } from 'typeorm';
import { PinoLogger } from 'nestjs-pino';
import { CreateSeriesDto } from './dto/create-series.dto';
import { DeleteSeriesDto } from './dto/delete-series.dto';
@Injectable()
export class SeriesService {
constructor(
@InjectRepository(SeriesEntity)
private seriesRepository: Repository<SeriesEntity>,
private logger: PinoLogger,
) { }
async deleteSeries(series: DeleteSeriesDto) {
return await this.seriesRepository.delete(series);
}
async updateSeries(series: CreateSeriesDto) {
return await this.seriesRepository.upsert(series, ['provider', 'providerSeriesId']);
}
}

View File

@ -0,0 +1,15 @@
export enum BookOriginType {
AUTHOR = 1,
ILLUSTRATOR = 2,
CATEGORY = 10,
LANGUAGE = 11,
MEDIA_TYPE = 12,
MATURITY_RATING = 20,
PROVIDER_THUMBNAIL = 30,
PROVIDER_URL = 31,
// 4x - Ratings
ISBN_10 = 40,
ISBN_13 = 41,
// 1xx - User-defined
TAGS = 100,
}

View File

@ -1,7 +1,7 @@
import * as argon2 from 'argon2';
import * as crypto from 'crypto';
import { UUID } from "crypto";
import { BookStatusEntity } from 'src/books/books/entities/book-status.entity';
import { BookStatusEntity } from 'src/books/entities/book-status.entity';
import { BigIntTransformer } from 'src/shared/transformers/bigint';
import { StringToLowerCaseTransformer } from 'src/shared/transformers/string';
import { BeforeInsert, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";