Create Library module. Moved book controller to library controller. Added series addition to library while adding all known volumes in background. Fixed Google search context.

This commit is contained in:
Tom
2025-02-28 00:19:26 +00:00
parent 64ebdfd6f4
commit 969829da20
29 changed files with 1121 additions and 239 deletions

View File

@ -15,6 +15,8 @@ import { serialize_token, serialize_user_short, serialize_user_long, serialize_r
import { BooksModule } from './books/books.module';
import { ProvidersModule } from './providers/providers.module';
import { SeriesModule } from './series/series.module';
import { LibraryModule } from './library/library.module';
import { BullModule } from '@nestjs/bullmq';
@Module({
imports: [
@ -23,6 +25,17 @@ import { SeriesModule } from './series/series.module';
imports: [ConfigModule],
useClass: DatabaseOptions
}),
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
connection: {
host: config.get('REDIS_HOST') ?? 'localhost',
port: config.get('REDIS_PORT') ?? 6379,
password: config.get('REDIS_PASSWORD'),
}
})
}),
TypeOrmModule.forFeature([UserEntity]),
UsersModule,
AuthModule,
@ -58,7 +71,8 @@ import { SeriesModule } from './series/series.module';
}),
BooksModule,
ProvidersModule,
SeriesModule
SeriesModule,
LibraryModule
],
controllers: [AppController],
providers: [AppService, UsersService],

View File

@ -197,7 +197,7 @@ export class AuthController {
msg: 'Failed to register due to duplicate userLogin.',
});
response.statusCode = 400;
response.statusCode = 409;
return {
success: false,
error_message: 'Username already exist.',

View File

@ -1,94 +0,0 @@
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

@ -1,5 +1,4 @@
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';
@ -8,6 +7,9 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { HttpModule } from '@nestjs/axios';
import { ProvidersModule } from 'src/providers/providers.module';
import { SeriesModule } from 'src/series/series.module';
import { LibraryService } from 'src/library/library.service';
import { LibraryModule } from 'src/library/library.module';
import { BullModule } from '@nestjs/bullmq';
@Module({
imports: [
@ -19,11 +21,15 @@ import { SeriesModule } from 'src/series/series.module';
SeriesModule,
HttpModule,
ProvidersModule,
LibraryModule,
BullModule.registerQueue({
name: 'library',
}),
],
controllers: [BooksController],
controllers: [],
exports: [
BooksService
],
providers: [BooksService]
providers: [BooksService, LibraryService]
})
export class BooksModule {}
export class BooksModule { }

View File

@ -5,13 +5,10 @@ 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';
import { CreateBookStatusDto } from './dto/create-book-status.dto';
import { DeleteBookStatusDto } from './dto/delete-book-status.dto';
@Injectable()
export class BooksService {
@ -22,82 +19,10 @@ export class BooksService {
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> {
async createBook(book: CreateBookDto): Promise<InsertResult> {
const entity = this.bookRepository.create(book);
return await this.bookRepository.createQueryBuilder()
.insert()
@ -107,12 +32,26 @@ export class BooksService {
.execute();
}
async addBookOrigin(bookId: UUID, type: BookOriginType, value: string): Promise<InsertResult> {
return await this.bookOriginRepository.insert({
bookId,
type,
value,
});
async addBookOrigin(origin: CreateBookOriginDto): Promise<InsertResult> {
return await this.bookOriginRepository.insert(origin);
}
async deleteBookOrigin(origin: CreateBookOriginDto) {
return await this.bookOriginRepository.createQueryBuilder()
.delete()
.where({
whereFactory: origin,
})
.execute();
}
async deleteBookStatus(status: DeleteBookStatusDto) {
return await this.bookStatusRepository.createQueryBuilder()
.delete()
.where({
whereFactory: status,
})
.execute();
}
async findBooksByIds(bookIds: UUID[]) {
@ -120,7 +59,7 @@ export class BooksService {
where: {
bookId: In(bookIds)
}
})
});
}
async findBookStatusesTrackedBy(userId: UUID): Promise<BookStatusEntity[]> {
@ -157,13 +96,12 @@ export class BooksService {
}, update);
}
async updateBookStatus(status: BookStatusDto) {
async updateBookStatus(status: CreateBookStatusDto) {
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,13 @@
import { IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { UUID } from 'crypto';
export class DeleteBookStatusDto {
@IsUUID()
@IsNotEmpty()
readonly bookId: UUID;
@IsUUID()
@IsNotEmpty()
@IsOptional()
readonly userId: UUID;
}

View File

@ -0,0 +1,93 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { GoogleSearchContext } from 'src/providers/contexts/google.search.context';
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
import { ProvidersService } from 'src/providers/providers.service';
import { CreateSeriesSubscriptionJobDto } from 'src/series/dto/create-series-subscription-job.dto';
import { LibraryService } from './library.service';
@Processor('library')
export class LibraryConsumer extends WorkerHost {
constructor(
private readonly library: LibraryService,
private readonly provider: ProvidersService,
private readonly logger: PinoLogger,
) {
super();
}
async process(job: Job, token?: string): Promise<any> {
console.log('job started:', job.name, job.data, job.id);
const series: CreateSeriesSubscriptionJobDto = job.data;
let context = this.provider.generateSearchContext(series.provider, series.title) as GoogleSearchContext;
//context.intitle = series.title;
context.maxResults = '40';
context.subject = 'Fiction';
// Search for the book(s) via the provider.
// Up until end of results or after 3 unhelpful pages of results.
let results = [];
let related = [];
let pageSearchedCount = 0;
let unhelpfulResultsCount = 0;
do {
pageSearchedCount += 1;
results = await this.provider.search(context);
const potential = results.filter(r => r.providerSeriesId == series.providerSeriesId || r.title == series.title);
if (potential.length > 0) {
related.push.apply(related, potential);
} else {
unhelpfulResultsCount += 1;
}
context = context.next();
job.updateProgress(pageSearchedCount * 5);
} while (results.length >= 40 && unhelpfulResultsCount < 3);
// Sort & de-duplicate the entries received.
const books = related.map(book => this.toScore(book, series))
.sort((a, b) => a.result.volume - b.result.volume || b.score - a.score)
.filter((_, index, arr) => index == 0 || arr[index - 1].result.volume != arr[index].result.volume);
job.updateProgress(25);
let counter = 0;
for (let book of books) {
try {
book.result.providerSeriesId = series.providerSeriesId;
await this.library.addBook(book.result);
} catch (err) {
this.logger.error({
class: LibraryConsumer.name,
method: this.process.name,
book: book.result,
score: book.score,
msg: 'Failed to add book in background.',
error: err,
});
} finally {
counter++;
job.updateProgress(25 + 75 * counter / books.length);
}
}
console.log('job completed:', job.name, job.data, job.id);
return null;
}
private toScore(book: BookSearchResultDto, series: CreateSeriesSubscriptionJobDto): ({ result: BookSearchResultDto, score: number }) {
if (!book) {
return {
result: null,
score: -1,
}
}
return {
result: book,
score: (!!book.providerSeriesId ? 50 : 0) + (book.title == series.title ? 25 : 0) + (book.url.startsWith('https://play.google.com/store/books/details?') ? 10 : 0),
}
}
}

View File

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

View File

@ -0,0 +1,258 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Body, Controller, Delete, Get, Post, Put, Request, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { Queue } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { BooksService } from 'src/books/books.service';
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
import { SeriesService } from 'src/series/series.service';
import { QueryFailedError } from 'typeorm';
import { UpdateBookDto } from 'src/books/dto/update-book.dto';
import { UpdateBookOriginDto } from 'src/books/dto/update-book-origin.dto';
import { LibraryService } from './library.service';
import { CreateSeriesSubscriptionDto } from 'src/series/dto/create-series-subscription.dto';
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
import { SeriesDto } from 'src/series/dto/series.dto';
import { CreateBookOriginDto } from 'src/books/dto/create-book-origin.dto';
import { DeleteBookStatusDto } from 'src/books/dto/delete-book-status.dto';
@UseGuards(JwtAccessGuard)
@Controller('library')
export class LibraryController {
constructor(
private readonly books: BooksService,
private readonly series: SeriesService,
private readonly library: LibraryService,
@InjectQueue('library') private readonly jobs: Queue,
private readonly logger: PinoLogger,
) { }
@Get('series')
async getSeries(
@Request() req,
@Res({ passthrough: true }) response: Response,
) {
return {
success: true,
data: await this.series.getAllSeries(),
};
}
@Post('series')
async createSeries(
@Request() req,
@Body() body: CreateSeriesSubscriptionDto,
@Res({ passthrough: true }) response: Response,
) {
try {
await this.library.addSeries({
provider: body.provider,
providerSeriesId: body.providerSeriesId,
title: body.title,
});
return {
success: true,
};
} catch (err) {
if (err instanceof QueryFailedError) {
if (err.driverError.code == '23505') {
// Subscription already exist.
response.statusCode = 409;
return {
success: false,
error_message: 'Series subscription already exists.',
};
}
}
response.statusCode = 500;
return {
success: false,
error_message: 'Something went wrong.',
};
}
}
@Get('series/subscriptions')
async getSeriesSubscriptions(
@Request() req,
@Res({ passthrough: true }) response: Response,
) {
return {
success: true,
data: await this.series.getSeriesSubscribedBy(req.user.userId),
};
}
@Post('series/subscribe')
async subscribe(
@Request() req,
@Body() body: SeriesDto,
@Res({ passthrough: true }) response: Response,
) {
try {
await this.library.addSubscription({
...body,
userId: req.user.userId,
});
return {
success: true,
};
} catch (err) {
if (err instanceof QueryFailedError) {
if (err.driverError.code == '23505') {
// Subscription already exists.
response.statusCode = 409;
return {
success: false,
error_message: 'Series subscription already exists.',
};
} else if (err.driverError.code == '23503') {
// Series does not exist.
response.statusCode = 400;
return {
success: false,
error_message: 'Series does not exist.',
};
}
}
response.statusCode = 500;
return {
success: false,
error_message: 'Something went wrong.',
};
}
}
@Get('books')
async getBooksFromUser(
@Request() req,
) {
return {
success: true,
data: await this.books.findBookStatusesTrackedBy(req.user.userId),
};
}
@Post('books')
async createBook(
@Request() req,
@Body() body: BookSearchResultDto,
@Res({ passthrough: true }) response: Response,
) {
if (body.provider && body.providerSeriesId) {
try {
await this.series.updateSeries({
provider: body.provider,
providerSeriesId: body.providerSeriesId,
title: body.title,
});
} catch (err) {
if (err instanceof QueryFailedError) {
// Ignore if the series already exist.
if (err.driverError.code != '23505') {
response.statusCode = 500;
return {
success: false,
error_message: 'Something went wrong.',
};
}
}
}
}
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 = 409;
return {
success: false,
error_message: 'The book has already been added previously.',
};
} else if (err.driverError.code == '23503') {
// Data dependency is missing.
response.statusCode = 500;
return {
success: false,
error_message: 'Series has not been added.',
};
}
}
this.logger.error({
class: LibraryController.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.',
};
}
}
@Put('books')
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,
};
}
@Delete('books/origins')
async deleteBookOrigin(
@Body() body: CreateBookOriginDto,
) {
const data = { ...body };
delete data['bookOriginId'];
const result = await this.books.deleteBookOrigin(body);
return {
success: result?.affected == 1,
};
}
@Delete('books/status')
async deleteBookStatus(
@Body() body: DeleteBookStatusDto,
) {
const data = { ...body };
delete data['bookOriginId'];
const result = await this.books.deleteBookStatus(body);
return {
success: result?.affected == 1,
};
}
@Put('books/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,37 @@
import { Module } from '@nestjs/common';
import { LibraryService } from './library.service';
import { HttpModule } from '@nestjs/axios';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookOriginEntity } from 'src/books/entities/book-origin.entity';
import { BookStatusEntity } from 'src/books/entities/book-status.entity';
import { BookEntity } from 'src/books/entities/book.entity';
import { ProvidersModule } from 'src/providers/providers.module';
import { SeriesModule } from 'src/series/series.module';
import { SeriesEntity } from 'src/series/entities/series.entity';
import { SeriesSubscriptionEntity } from 'src/series/entities/series-subscription.entity';
import { BooksService } from 'src/books/books.service';
import { SeriesService } from 'src/series/series.service';
import { BullModule } from '@nestjs/bullmq';
import { LibraryConsumer } from './library.consumer';
import { LibraryController } from './library.controller';
@Module({
imports: [
TypeOrmModule.forFeature([
BookEntity,
BookOriginEntity,
BookStatusEntity,
SeriesEntity,
SeriesSubscriptionEntity,
]),
BullModule.registerQueue({
name: 'library',
}),
SeriesModule,
HttpModule,
ProvidersModule,
],
providers: [LibraryService, BooksService, SeriesService, LibraryConsumer],
controllers: [LibraryController]
})
export class LibraryModule { }

View File

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

View File

@ -0,0 +1,138 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { BooksService } from 'src/books/books.service';
import { CreateBookDto } from 'src/books/dto/create-book.dto';
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
import { CreateSeriesDto } from 'src/series/dto/create-series.dto';
import { SeriesSubscriptionDto } from 'src/series/dto/series-subscription.dto';
import { SeriesService } from 'src/series/series.service';
import { BookOriginType } from 'src/shared/enums/book_origin_type';
@Injectable()
export class LibraryService {
constructor(
private readonly books: BooksService,
private readonly series: SeriesService,
@InjectQueue('library') private readonly jobs: Queue,
private readonly logger: PinoLogger,
) { }
async addSeries(series: CreateSeriesDto) {
const result = await this.series.addSeries(series);
this.logger.debug({
class: LibraryService.name,
method: this.addSubscription.name,
series: series.providerSeriesId,
msg: 'Series saved to database.',
});
this.jobs.add('new_series', series);
return {
success: true,
};
}
async addSubscription(series: SeriesSubscriptionDto) {
return await this.series.addSeriesSubscription({
userId: series.userId,
providerSeriesId: series.providerSeriesId,
provider: series.provider,
});
}
async addBook(book: BookSearchResultDto) {
this.logger.debug({
class: LibraryService.name,
method: this.addBook.name,
book: book,
msg: 'Saving book to database...',
});
const bookData = await this.books.createBook({
title: book.title,
desc: book.desc,
providerSeriesId: book.providerSeriesId,
providerBookId: book.providerBookId,
volume: book.volume,
provider: book.provider,
publishedAt: book.publishedAt,
});
const bookId = bookData.identifiers[0]['bookId'];
const tasks = [];
if (book.authors && book.authors.length > 0) {
tasks.push(book.authors.map(author => this.books.addBookOrigin({
bookId,
type: BookOriginType.AUTHOR,
value: author,
})));
}
if (book.categories && book.categories.length > 0) {
tasks.push(book.categories.map(category => this.books.addBookOrigin({
bookId,
type: BookOriginType.CATEGORY,
value: category
})));
}
if (book.language) {
tasks.push(this.books.addBookOrigin({
bookId,
type: BookOriginType.LANGUAGE,
value: book.language,
}));
}
if (book.maturityRating) {
tasks.push(this.books.addBookOrigin({
bookId,
type: BookOriginType.MATURITY_RATING,
value: book.maturityRating,
}));
}
if (book.thumbnail) {
tasks.push(this.books.addBookOrigin({
bookId,
type: BookOriginType.PROVIDER_THUMBNAIL,
value: book.thumbnail,
}));
}
if (book.url) {
tasks.push(this.books.addBookOrigin({
bookId,
type: BookOriginType.PROVIDER_URL,
value: book.url,
}));
}
if ('ISBN_10' in book.industryIdentifiers) {
tasks.push(this.books.addBookOrigin({
bookId,
type: BookOriginType.ISBN_10,
value: book.industryIdentifiers['ISBN_10'],
}));
}
if ('ISBN_13' in book.industryIdentifiers) {
tasks.push(this.books.addBookOrigin({
bookId,
type: BookOriginType.ISBN_10,
value: book.industryIdentifiers['ISBN_13'],
}));
}
await Promise.all(tasks);
this.logger.info({
class: LibraryService.name,
method: this.addBook.name,
book: book,
msg: 'Book saved to database.',
});
return bookId;
}
}

View File

@ -1,4 +1,6 @@
class GoogleSearchContext extends SearchContext {
import { SearchContext } from "./search.context";
export class GoogleSearchContext extends SearchContext {
constructor(searchQuery: string, params: { [key: string]: string }) {
super('google', searchQuery, params);
}
@ -19,12 +21,96 @@ class GoogleSearchContext extends SearchContext {
...searchParams.map(p => this.params[p] ? p + ':"' + this.params[p] + '"' : ''),
].filter(p => p.length > 0).join('');
return queryParams + '&' + searchQueryParam;
return [queryParams, 'q=' + searchQueryParam].filter(q => q.length > 0).join('&');
}
get maxResults(): number {
return 'maxResults' in this.params ? parseInt(this.params['maxResults']) : 10;
}
set maxResults(value: string) {
if (!value || isNaN(parseInt(value))) {
return;
}
this.params['maxResults'] = value;
}
get startIndex(): number {
return 'startIndex' in this.params ? parseInt(this.params['startIndex']) : 10;
}
set startIndex(value: string) {
if (!value || isNaN(parseInt(value))) {
return;
}
this.params['startIndex'] = value;
}
get intitle(): string {
return 'intitle' in this.params ? this.params['intitle'] : null;
}
set intitle(value: string) {
if (!value) {
delete this.params['intitle'];
} else {
this.params['intitle'] = value;
}
}
get inpublisher(): string {
return 'inpublisher' in this.params ? this.params['inpublisher'] : null;
}
set inpublisher(value: string) {
if (!value) {
delete this.params['inpublisher'];
} else {
this.params['inpublisher'] = value;
}
}
get inauthor(): string {
return 'inauthor' in this.params ? this.params['inauthor'] : null;
}
set inauthor(value: string) {
if (!value) {
delete this.params['inauthor'];
} else {
this.params['inauthor'] = value;
}
}
get isbn(): string {
return 'isbn' in this.params ? this.params['isbn'] : null;
}
set isbn(value: string) {
if (!value) {
delete this.params['isbn'];
} else {
this.params['isbn'] = value;
}
}
get subject(): string {
return 'subject' in this.params ? this.params['subject'] : null;
}
set subject(value: string) {
if (!value) {
delete this.params['subject'];
} else {
this.params['subject'] = value;
}
}
next() {
const resultsPerPage = parseInt(this.params['maxResults']) ?? 10;
const index = parseInt(this.params['startIndex']) ?? 0;
const resultsPerPage = this.params['maxResults'] ? parseInt(this.params['maxResults']) : 10;
const index = this.params['startIndex'] ? parseInt(this.params['startIndex']) : 0;
const data = { ...this.params };
data['startIndex'] = (index + resultsPerPage).toString();

View File

@ -1,4 +1,4 @@
abstract class SearchContext {
export abstract class SearchContext {
provider: string;
search: string;
params: { [key: string]: string };

View File

@ -3,6 +3,7 @@ import { BookSearchResultDto } from '../dto/book-search-result.dto';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom, map, timeout } from 'rxjs';
import { AxiosResponse } from 'axios';
import { GoogleSearchContext } from '../contexts/google.search.context';
@Injectable()
export class GoogleService {
@ -11,7 +12,7 @@ export class GoogleService {
) { }
async searchRaw(searchQuery: string): Promise<BookSearchResultDto[]> {
const queryParams = 'printType=books&maxResults=10&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))&q=';
const queryParams = 'langRestrict=en&printType=books&maxResults=10&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))&q=';
return await firstValueFrom(
this.http.get('https://www.googleapis.com/books/v1/volumes?' + queryParams + searchQuery)
@ -23,14 +24,19 @@ export class GoogleService {
}
async search(context: GoogleSearchContext): Promise<BookSearchResultDto[]> {
const defaultQueryParams = 'printType=books&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))';
if (!context) {
return null;
}
const defaultQueryParams = 'langRestrict=en&printType=books&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))';
const customQueryParams = context.generateQueryParams();
console.log(defaultQueryParams, customQueryParams);
return await firstValueFrom(
this.http.get('https://www.googleapis.com/books/v1/volumes?' + defaultQueryParams + '&' + customQueryParams)
.pipe(
timeout({ first: 5000 }),
map(this.transform),
map(value => this.transform(value)),
)
);
}
@ -40,7 +46,9 @@ export class GoogleService {
return [];
}
return response.data.items.map(item => this.extract(item));
return response.data.items
//.filter(item => item.volumeInfo?.canonicalVolumeLink?.startsWith('https://play.google.com/store/books/details'))
.map(item => this.extract(item));
}
private extract(item: any): BookSearchResultDto {
@ -49,12 +57,12 @@ export class GoogleService {
providerSeriesId: item.volumeInfo.seriesInfo?.volumeSeries[0].seriesId,
title: item.volumeInfo.title,
desc: item.volumeInfo.description,
volume: parseInt(item.volumeInfo.seriesInfo?.bookDisplayNumber),
volume: item.volumeInfo.seriesInfo?.bookDisplayNumber ? parseInt(item.volumeInfo.seriesInfo?.bookDisplayNumber, 10) : undefined,
publisher: item.volumeInfo.publisher,
authors: item.volumeInfo.authors,
categories: item.volumeInfo.categories,
maturityRating: item.volumeInfo.maturityRating,
industryIdentifiers: Object.assign({}, ...item.volumeInfo.industryIdentifiers.map(i => ({ [i.type]: i.identifier }))),
industryIdentifiers: item.volumeInfo.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,
@ -62,15 +70,13 @@ export class GoogleService {
provider: 'google'
}
if (result.providerSeriesId) {
let regex = this.getRegexByPublisher(result.publisher);
let regex = this.getRegexByPublisher(result.publisher);
const match = result.title.match(regex);
if (match?.groups) {
result.title = match.groups['title'].trim();
if (!result.volume) {
result.volume = parseInt(match.groups['volume']);
}
const match = result.title.match(regex);
if (match?.groups) {
result.title = match.groups['title'].trim();
if (!result.volume || isNaN(result.volume)) {
result.volume = parseInt(match.groups['volume'], 10);
}
}

View File

@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { GoogleService } from './google/google.service';
import { BookSearchResultDto } from './dto/book-search-result.dto';
import { GoogleSearchContext } from './contexts/google.search.context';
import { SearchContext } from './contexts/search.context';
@Injectable()
export class ProvidersService {
@ -10,7 +12,7 @@ export class ProvidersService {
generateSearchContext(providerName: string, searchQuery: string): SearchContext | null {
let params: { [key: string]: string } = {};
if (providerName == 'google') {
if (providerName.toLowerCase() == 'google') {
return new GoogleSearchContext(searchQuery, params);
}
return null;
@ -28,7 +30,7 @@ export class ProvidersService {
async search(context: SearchContext): Promise<BookSearchResultDto[]> {
switch (context.provider.toLowerCase()) {
case 'google':
return await this.google.search(context);
return await this.google.search(context as GoogleSearchContext);
default:
throw Error('Invalid provider name.');
}

View File

@ -0,0 +1,8 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { SeriesSubscriptionDto } from './series-subscription.dto';
export class CreateSeriesSubscriptionJobDto extends SeriesSubscriptionDto {
@IsString()
@IsNotEmpty()
title: string;
}

View File

@ -0,0 +1,8 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { SeriesDto } from './series.dto';
export class CreateSeriesSubscriptionDto extends SeriesDto {
@IsString()
@IsNotEmpty()
title: string;
}

View File

@ -1,15 +1,8 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { SeriesDto } from './series.dto';
export class CreateSeriesDto {
@IsString()
@IsNotEmpty()
providerSeriesId: string;
export class CreateSeriesDto extends SeriesDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
provider: string;
}

View File

@ -0,0 +1,9 @@
import { IsNotEmpty, IsUUID } from 'class-validator';
import { SeriesDto } from './series.dto';
import { UUID } from 'crypto';
export class SeriesSubscriptionDto extends SeriesDto {
@IsUUID()
@IsNotEmpty()
userId: UUID;
}

View File

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

View File

@ -1,20 +1,9 @@
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { IsNotEmpty, IsUUID } from 'class-validator';
import { UUID } from 'crypto';
import { CreateSeriesDto } from './create-series.dto';
export class UpdateSeriesDto {
export class UpdateSeriesDto extends CreateSeriesDto {
@IsUUID()
@IsNotEmpty()
seriesId: UUID;
@IsString()
@IsNotEmpty()
providerSeriesId: string;
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
provider: string;
}

View File

@ -0,0 +1,17 @@
import { UUID } from 'crypto';
import { Column, Entity, PrimaryColumn, Unique } from 'typeorm';
@Entity("series_subscriptions")
export class SeriesSubscriptionEntity {
@PrimaryColumn({ name: 'user_id', type: 'uuid' })
readonly userId: UUID;
@PrimaryColumn({ name: 'provider_series_id', type: 'text' })
providerSeriesId: string;
@PrimaryColumn({ name: 'provider', type: 'text' })
provider: string;
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
addedAt: Date;
}

View File

@ -4,11 +4,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { SeriesEntity } from './entities/series.entity';
import { HttpModule } from '@nestjs/axios';
import { ProvidersModule } from 'src/providers/providers.module';
import { SeriesSubscriptionEntity } from './entities/series-subscription.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
SeriesEntity,
SeriesSubscriptionEntity,
]),
HttpModule,
ProvidersModule,

View File

@ -2,23 +2,56 @@ 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';
import { SeriesDto } from './dto/series.dto';
import { SeriesSubscriptionEntity } from './entities/series-subscription.entity';
import { UUID } from 'crypto';
import { SeriesSubscriptionDto } from './dto/series-subscription.dto';
@Injectable()
export class SeriesService {
constructor(
@InjectRepository(SeriesEntity)
private seriesRepository: Repository<SeriesEntity>,
private logger: PinoLogger,
@InjectRepository(SeriesSubscriptionEntity)
private seriesSubscriptionRepository: Repository<SeriesSubscriptionEntity>,
) { }
async deleteSeries(series: DeleteSeriesDto) {
async addSeries(series: CreateSeriesDto) {
return await this.seriesRepository.insert(series);
}
async addSeriesSubscription(series: SeriesSubscriptionDto) {
return await this.seriesSubscriptionRepository.insert(series);
}
async deleteSeries(series: SeriesDto) {
return await this.seriesRepository.delete(series);
}
async deleteSeriesSubscription(subscription: SeriesSubscriptionDto) {
return await this.seriesSubscriptionRepository.delete(subscription);
}
async getSeries(series: SeriesDto) {
return await this.seriesRepository.findOne({
where: series
})
}
async getAllSeries() {
return await this.seriesRepository.find()
}
async getSeriesSubscribedBy(userId: UUID) {
return await this.seriesSubscriptionRepository.find({
where: {
userId,
}
});
}
async updateSeries(series: CreateSeriesDto) {
return await this.seriesRepository.upsert(series, ['provider', 'providerSeriesId']);
}