diff --git a/.env.sample b/.env.sample index 40dfcb9..7dc4976 100644 --- a/.env.sample +++ b/.env.sample @@ -1,5 +1,9 @@ PORT=3000 -DB_PATH=./data/db/db.db -LOG_FILE_REQUESTS=./data/static/static.log -STATIC_SERVE_FOLDER=./data/static -RECENT_SEARCHES_FOLDER=./data/recent \ No newline at end of file +STATIC_SERVE_FOLDER="data/static" +LOG_FILE_REQUESTS="data/static/static_log.txt" +DB_PATH="./data/db/db.db" +RECENT_SEARCHES_FOLDER="./data/recent_searches" +TEST_ARRAY='[ + "one", + "two" +]' diff --git a/README.md b/README.md index 6b61b80..775e96f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,15 @@ A rewrite attempt from "vanilla" node.js into framework based (Nest.js). No licence +## Migrations +```bash +# create migration +$ npm run typeorm:create-migration -name=CreateFilesTable + +# run migration +$ npm run typeorm:run-migrations +``` + ## Below are left from nest readme ## Installation diff --git a/migrations/1672729641013-CreateFilesTable.ts b/migrations/1672729641013-CreateFilesTable.ts new file mode 100644 index 0000000..25803df --- /dev/null +++ b/migrations/1672729641013-CreateFilesTable.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateFilesTable1672729641013 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `create table files + ( + id INTEGER + primary key autoincrement, + path VARCHAR(512), + deleted TINYINT, + size INTEGER, + file_name VARCHAR(512), + infos TEXT, + info TEXT, + title_id INTEGER, + season INTEGER default NULL, + duration REAL, + transmission INTEGER default NULL, + lm INTEGER default NULL + );`, + ); + + await queryRunner.query( + `create unique index path + on files (path);`, + ); + + await queryRunner.query( + `create index season + on files (season);`, + ); + + await queryRunner.query( + `create index title_id + on files (title_id);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE files`); + } +} diff --git a/migrations/1672729964237-CreateTitlesTable.ts b/migrations/1672729964237-CreateTitlesTable.ts new file mode 100644 index 0000000..3a56d00 --- /dev/null +++ b/migrations/1672729964237-CreateTitlesTable.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateTitlesTable1672729964237 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `create table titles + ( + id INTEGER + primary key, + title VARCHAR(512), + type VARCHAR(50) + );`, + ); + + await queryRunner.query( + `create unique index title_type + on titles (title, type);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE titles`); + } + +} diff --git a/migrations/1672730402752-CreateLrtCategoriesTable.ts b/migrations/1672730402752-CreateLrtCategoriesTable.ts new file mode 100644 index 0000000..1940853 --- /dev/null +++ b/migrations/1672730402752-CreateLrtCategoriesTable.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateLrtCategoriesTable1672730402752 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `create table lrt_categories + ( + category_url TEXT, + category_id TEXT + );`, + ); + + await queryRunner.query( + `create index lrt_categories_category_url_index + on lrt_categories (category_url);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE lrt_categories`); + } +} diff --git a/package.json b/package.json index 4ea9077..8e749e9 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,18 @@ "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", + "start:prod": "node dist/src/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "typeorm": "ts-node ./node_modules/typeorm/cli", + "typeorm:run-migrations": "npm run typeorm migration:run -- -d ./src/config/type-orm-migrations.config.ts", + "typeorm:generate-migration": "npm run typeorm -- -d ./src/config/type-orm-migrations.config.ts migration:generate ./migrations/$npm_config_name", + "typeorm:create-migration": "npm run typeorm -- migration:create ./migrations/$npm_config_name", + "typeorm:revert-migration": "npm run typeorm -- -d ./src/config/type-orm-migrations.config.ts migration:revert" }, "dependencies": { "@nestjs/axios": "^1.0.0", diff --git a/src/Exception/http-exception.filter.ts b/src/Exception/http-exception.filter.ts new file mode 100644 index 0000000..744335f --- /dev/null +++ b/src/Exception/http-exception.filter.ts @@ -0,0 +1,29 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, +} from '@nestjs/common'; +import { Response } from 'express'; + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + //const request = ctx.getRequest(); + const status = exception.getStatus(); + + // 200 response is bad practice, + // but currently it is the only way KODi will show what happened + response.status(200).json({ + // original response: + response: exception.getResponse(), + // for KODI: + notification: status + ': ' + exception.message, + refreshContainer: false, + updateList: false, + // msgBoxOK: status + ': ' + exception.message, + }); + } +} diff --git a/src/KodiApi/AllFiles/all-files.controller.ts b/src/KodiApi/AllFiles/all-files.controller.ts index 4f13643..5fdf93c 100644 --- a/src/KodiApi/AllFiles/all-files.controller.ts +++ b/src/KodiApi/AllFiles/all-files.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Head, Param, Req, Res } from '@nestjs/common'; import ApiResponse from '../Dto/api-response.dto'; import { AllFilesService } from './all-files.service'; -import { TitleTypeEnum } from './Enum/title-type.enum'; import { Request, Response } from 'express'; +import { TitleTypeEnum } from '../../VideoFiles/Enum/title-type.enum'; @Controller('api/all') export class AllFilesController { diff --git a/src/KodiApi/AllFiles/all-files.module.ts b/src/KodiApi/AllFiles/all-files.module.ts index dbcba83..bac7ab2 100644 --- a/src/KodiApi/AllFiles/all-files.module.ts +++ b/src/KodiApi/AllFiles/all-files.module.ts @@ -2,16 +2,11 @@ import { Module } from '@nestjs/common'; import { AllFilesController } from './all-files.controller'; import { AllFilesService } from './all-files.service'; import { KodiApiResponseFactory } from '../kodi-api-response.factory'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { TitleEntity } from './Entity/title.entity'; -import { FileEntity } from './Entity/file.entity'; import { StreamerModule } from '../../Streamer/streamer.module'; +import { VideoFilesModule } from '../../VideoFiles/video-files.module'; @Module({ - imports: [ - TypeOrmModule.forFeature([TitleEntity, FileEntity]), - StreamerModule, - ], + imports: [StreamerModule, VideoFilesModule], controllers: [AllFilesController], providers: [AllFilesService, KodiApiResponseFactory], }) diff --git a/src/KodiApi/AllFiles/all-files.service.ts b/src/KodiApi/AllFiles/all-files.service.ts index 4792004..a38138c 100644 --- a/src/KodiApi/AllFiles/all-files.service.ts +++ b/src/KodiApi/AllFiles/all-files.service.ts @@ -1,23 +1,19 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import ApiResponse from '../Dto/api-response.dto'; import { KodiApiResponseFactory } from '../kodi-api-response.factory'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { TitleEntity } from './Entity/title.entity'; -import { FileEntity } from './Entity/file.entity'; -import { TitleTypeEnum } from './Enum/title-type.enum'; import { StreamerFacade } from '../../Streamer/streamer.facade'; import { Request, Response } from 'express'; +import { VideoFilesProvider } from '../../VideoFiles/video-files.provider'; +import { TitleTypeEnum } from '../../VideoFiles/Enum/title-type.enum'; +import { FileEntity } from '../../VideoFiles/Entity/file.entity'; +import { TitleEntity } from '../../VideoFiles/Entity/title.entity'; @Injectable() export class AllFilesService { constructor( private readonly koiApiResponseFactory: KodiApiResponseFactory, - @InjectRepository(TitleEntity) - private titleRepository: Repository, - @InjectRepository(FileEntity) - private fileRepository: Repository, private readonly streamerFacade: StreamerFacade, + private readonly videoTitlesProvider: VideoFilesProvider, ) {} getMenu(): ApiResponse { @@ -37,13 +33,7 @@ export class AllFilesService { } async getListOfTitles(type: TitleTypeEnum): Promise { - const entities = await this.titleRepository - .createQueryBuilder() - .setFindOptions({ relations: { files: true } }) - .where('type = :type', { type: type }) - .andWhere('deleted = :deleted', { deleted: false }) - .getMany(); - + const entities = await this.videoTitlesProvider.getListOfTitles(type); const response = this.koiApiResponseFactory.createApiResponse(); response.setTitle('All ' + type + 's'); entities.forEach((entity) => { @@ -51,7 +41,9 @@ export class AllFilesService { .createItem() .setLabel(entity.title) .setToFolder() - .setPlot(entity.files.length + ' files') + .setPlot( + entity.files.length + (entity.files.length > 1 ? ' files' : ' file'), + ) .setPath('all/' + entity.id); }); @@ -59,15 +51,15 @@ export class AllFilesService { } async loadTitle(titleId: number, seasonId: number): Promise { - const titleEntity = await this.titleRepository.findOne({ - relations: { files: true }, - where: { id: titleId }, - }); + const titleEntity = titleId + ? await this.videoTitlesProvider.getTitleWithFiles(titleId) + : null; if (titleEntity === null) { + //todo - alert instead of full api response return this.koiApiResponseFactory .createApiResponse() - .setTitle('Not found ' + titleId); + .setTitle('Not found' + (titleId ? ' ' + titleId : '')); } if (titleEntity.type === TitleTypeEnum.movie) { @@ -112,10 +104,13 @@ export class AllFilesService { //todo - add num files count const seasons = []; + const filesCount = {}; for (const fileEntity of titleEntity.files) { if (seasons.indexOf(fileEntity.season) === -1) { seasons.push(fileEntity.season); + filesCount[fileEntity.season] = 0; } + filesCount[fileEntity.season]++; } for (const season of seasons) { @@ -123,6 +118,9 @@ export class AllFilesService { .createItem() .setLabel(season + ' Sezonas') .setToFolder() + .setPlot( + filesCount[season] + (filesCount[season] > 1 ? ' files' : ' file'), + ) .setPath(`all/${titleEntity.id}/${season}`); } @@ -138,13 +136,12 @@ export class AllFilesService { if (isNaN(id)) { throw new NotFoundException(`id #${fileId} not correct`); } + const filePath = await this.videoTitlesProvider.getFilePath(id); - const entity = await this.fileRepository.findOne({ where: { id: id } }); - - if (entity === null) { + if (filePath === null) { throw new NotFoundException(`id #${fileId} not found`); } - return this.streamerFacade.streamFile(request, response, entity.path); + return this.streamerFacade.streamFile(request, response, filePath); } } diff --git a/src/KodiApi/Dto/api-response.dto.ts b/src/KodiApi/Dto/api-response.dto.ts index 1bf6f26..53ba6f6 100644 --- a/src/KodiApi/Dto/api-response.dto.ts +++ b/src/KodiApi/Dto/api-response.dto.ts @@ -4,10 +4,10 @@ export default class ApiResponse { items: Array = []; content = 'videos'; updateList = true; - category: string; - play: string; - msgBoxOK: string; - nosort: boolean; + category: string | undefined; + play: string | undefined; + msgBoxOK: string | undefined; + nosort: boolean | undefined; setTitle(title) { this.category = title; diff --git a/src/KodiApi/LRT/LrtApiClient/Client/lrt-api-search.client.ts b/src/KodiApi/LRT/LrtApiClient/Client/lrt-api-search.client.ts index 5d90adf..a0cb798 100644 --- a/src/KodiApi/LRT/LrtApiClient/Client/lrt-api-search.client.ts +++ b/src/KodiApi/LRT/LrtApiClient/Client/lrt-api-search.client.ts @@ -25,6 +25,7 @@ export class LrtApiSearchClient { const searchResponse: SearchResponseInterface = resp.data; const cats = []; + const loadedCategories = new Set(); //todo - move to mapper const responseDto = new SearchResponseDto( @@ -36,6 +37,9 @@ export class LrtApiSearchClient { if (cats.indexOf(searchItem.category_title) > -1) { continue; } + if (loadedCategories.has(searchItem.category_title)) { + continue; + } const itemDto = new SearchResponseItemDto(); itemDto.label = searchItem.category_title; itemDto.thumb = @@ -46,6 +50,7 @@ export class LrtApiSearchClient { itemDto.categoryId = await this.findCategoryId(searchItem.category_url); cats.push(searchItem.category_title); + loadedCategories.add(searchItem.category_title); responseDto.addItem(itemDto); } @@ -83,7 +88,6 @@ export class LrtApiSearchClient { } private async findCategoryIdInLrt(categoryUrl: string): Promise { - //todo - cache to database const url = 'https://www.lrt.lt' + categoryUrl; const resp = await firstValueFrom(this.httpService.get(url)); const body: string = resp.data; diff --git a/src/KodiApi/LRT/LrtApiClient/Entity/lrt-category.entity.ts b/src/KodiApi/LRT/LrtApiClient/Entity/lrt-category.entity.ts index f2454de..6b80371 100644 --- a/src/KodiApi/LRT/LrtApiClient/Entity/lrt-category.entity.ts +++ b/src/KodiApi/LRT/LrtApiClient/Entity/lrt-category.entity.ts @@ -1,6 +1,6 @@ import { Column, Entity, PrimaryColumn } from 'typeorm'; -@Entity() +@Entity('lrt_categories') export class LrtCategory { @PrimaryColumn({ name: 'category_url' }) categoryUrl: string; diff --git a/src/Static/static.service.spec.ts b/src/Static/static.service.spec.ts new file mode 100644 index 0000000..8f3a8a7 --- /dev/null +++ b/src/Static/static.service.spec.ts @@ -0,0 +1,19 @@ +import { StaticService } from './static.service'; + +describe('StaticService', () => { + let staticService: StaticService; + + beforeEach(() => { + staticService = new StaticService(); + }); + + describe('provideDirIndex', () => { + it('should return file/dir list of dir', async () => { + jest.spyOn(staticService, 'provideDirIndex'); + process.env.STATIC_SERVE_FOLDER = 'test/Static/_test-data/StaticServe'; + + const result = await staticService.provideDirIndex(''); + expect(result).toHaveLength(2); + }); + }); +}); diff --git a/src/TryOutModule/try.cotroller.ts b/src/TryOutModule/try.cotroller.ts index 5911a16..b665caf 100644 --- a/src/TryOutModule/try.cotroller.ts +++ b/src/TryOutModule/try.cotroller.ts @@ -1,11 +1,9 @@ import { Controller, Get } from '@nestjs/common'; -import { EntityManager } from 'typeorm'; @Controller('try') export class TryController { - constructor(private orm: EntityManager) {} @Get('') - async main() { - return await this.orm.query('select * from aliases'); + main() { + return process.env; } } diff --git a/src/TryOutModule/try.module.ts b/src/TryOutModule/try.module.ts index dbd0c72..af92de8 100644 --- a/src/TryOutModule/try.module.ts +++ b/src/TryOutModule/try.module.ts @@ -1,5 +1,4 @@ import { Module } from '@nestjs/common'; - import { TryController } from './try.cotroller'; /** diff --git a/src/KodiApi/AllFiles/Entity/file.entity.ts b/src/VideoFiles/Entity/file.entity.ts similarity index 91% rename from src/KodiApi/AllFiles/Entity/file.entity.ts rename to src/VideoFiles/Entity/file.entity.ts index 37784d8..25af9b0 100644 --- a/src/KodiApi/AllFiles/Entity/file.entity.ts +++ b/src/VideoFiles/Entity/file.entity.ts @@ -15,9 +15,6 @@ export class FileEntity { @Column() size: number; - @Column({ name: 'relative_path' }) - relativePath: string; - @Column({ name: 'file_name' }) fileName: string; diff --git a/src/KodiApi/AllFiles/Entity/title.entity.ts b/src/VideoFiles/Entity/title.entity.ts similarity index 100% rename from src/KodiApi/AllFiles/Entity/title.entity.ts rename to src/VideoFiles/Entity/title.entity.ts diff --git a/src/KodiApi/AllFiles/Enum/title-type.enum.ts b/src/VideoFiles/Enum/title-type.enum.ts similarity index 100% rename from src/KodiApi/AllFiles/Enum/title-type.enum.ts rename to src/VideoFiles/Enum/title-type.enum.ts diff --git a/src/VideoFiles/video-files.module.ts b/src/VideoFiles/video-files.module.ts new file mode 100644 index 0000000..53401ab --- /dev/null +++ b/src/VideoFiles/video-files.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TitleEntity } from './Entity/title.entity'; +import { FileEntity } from './Entity/file.entity'; +import { VideoFilesProvider } from './video-files.provider'; + +@Module({ + imports: [TypeOrmModule.forFeature([TitleEntity, FileEntity])], + controllers: [], + providers: [VideoFilesProvider], + exports: [VideoFilesProvider], +}) +export class VideoFilesModule {} diff --git a/src/VideoFiles/video-files.provider.ts b/src/VideoFiles/video-files.provider.ts new file mode 100644 index 0000000..e7b3741 --- /dev/null +++ b/src/VideoFiles/video-files.provider.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TitleEntity } from './Entity/title.entity'; +import { TitleTypeEnum } from './Enum/title-type.enum'; +import { FileEntity } from './Entity/file.entity'; + +@Injectable() +export class VideoFilesProvider { + constructor( + @InjectRepository(TitleEntity) + private readonly titleRepository: Repository, + @InjectRepository(FileEntity) + private readonly fileRepository: Repository, + ) {} + + async getListOfTitles(type: TitleTypeEnum): Promise { + return this.titleRepository + .createQueryBuilder() + .setFindOptions({ relations: { files: true } }) + .where('type = :type', { type: type }) + .andWhere('deleted = :deleted', { deleted: false }) + .getMany(); + } + + async getTitleWithFiles(titleId: number): Promise { + return this.titleRepository + .findOne({ + relations: { files: true }, + where: { id: titleId }, + }) + .catch(() => null); + } + + async getFilePath(fileId: number): Promise { + const entity = await this.fileRepository + .findOne({ where: { id: fileId } }) + .catch(() => null); + + if (entity === null) { + return null; + } + + return entity.path; + } +} diff --git a/src/config/paths.config.ts b/src/config/paths.config.ts index 912c748..7020ad3 100644 --- a/src/config/paths.config.ts +++ b/src/config/paths.config.ts @@ -7,27 +7,33 @@ export class PathsConfig { } public getStaticRequestsLogPath(): string { - return join( - this.getRootPath(), - this.configService.getEnv('LOG_FILE_REQUESTS'), - ); + return this.getPathByEnvKey('LOG_FILE_REQUESTS'); } public getStaticFolder(): string { - return join( - this.getRootPath(), - this.configService.getEnv('STATIC_SERVE_FOLDER'), - ); + return this.getPathByEnvKey('STATIC_SERVE_FOLDER'); } public getRecentSearchesFolder(): string { - return join( - this.getRootPath(), - this.configService.getEnv('RECENT_SEARCHES_FOLDER'), - ); + return this.getPathByEnvKey('RECENT_SEARCHES_FOLDER'); + } + + private getPathByEnvKey(key: string) { + const relPath = this.configService.getEnv(key); + + return this.getPath(relPath); + } + + private getPath(relPath: string) { + if (relPath[0] === '/') { + //this is an absolute path + return relPath; + } + + return join(this.getRootPath(), relPath); } private getRootPath(): string { - return join(__dirname, '../..'); + return this.configService.getEnv('PWD'); } } diff --git a/src/config/type-orm-migrations.config.ts b/src/config/type-orm-migrations.config.ts new file mode 100644 index 0000000..95e6ed6 --- /dev/null +++ b/src/config/type-orm-migrations.config.ts @@ -0,0 +1,11 @@ +import { DataSource, DataSourceOptions } from 'typeorm'; +import { configService } from './config.service'; + +/** + * config for typeorm cli + */ +export default new DataSource({ + type: configService.getTypeOrmConfig().type, + database: configService.getTypeOrmConfig().database, + migrations: ['migrations/*{.ts,.js}'], +}); diff --git a/src/config/type-orm.config.ts b/src/config/type-orm.config.ts index 4720e63..c113410 100644 --- a/src/config/type-orm.config.ts +++ b/src/config/type-orm.config.ts @@ -1,8 +1,8 @@ import { LrtCategory } from '../KodiApi/LRT/LrtApiClient/Entity/lrt-category.entity'; -import { TitleEntity } from '../KodiApi/AllFiles/Entity/title.entity'; -import { FileEntity } from '../KodiApi/AllFiles/Entity/file.entity'; import { ConfigService } from './config.service'; import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type'; +import { TitleEntity } from '../VideoFiles/Entity/title.entity'; +import { FileEntity } from '../VideoFiles/Entity/file.entity'; export class TypeOrmConfig { constructor(configService: ConfigService) { diff --git a/src/main.ts b/src/main.ts index 0e7a8c8..79b0789 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,11 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { configService } from './config/config.service'; +import { HttpExceptionFilter } from './Exception/http-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.useGlobalFilters(new HttpExceptionFilter()); await app.listen(configService.getPort()); } diff --git a/test/Static/_test-data/StaticServe/test-dir/test-file-2 b/test/Static/_test-data/StaticServe/test-dir/test-file-2 new file mode 100644 index 0000000..e69de29 diff --git a/test/Static/_test-data/StaticServe/test-file b/test/Static/_test-data/StaticServe/test-file new file mode 100644 index 0000000..e69de29