migrations; torr search, dl, seed, delete; files scan, delete; configs

This commit is contained in:
Jurgis Sakalauskas
2023-02-03 17:41:40 +02:00
parent 8d354027fc
commit daaeda539b
84 changed files with 5456 additions and 301 deletions
+8 -1
View File
@@ -3,8 +3,15 @@ STATIC_SERVE_FOLDER="data/static"
LOG_FILE_REQUESTS="data/static/static_log.txt" LOG_FILE_REQUESTS="data/static/static_log.txt"
DB_PATH="./data/db/db.db" DB_PATH="./data/db/db.db"
RECENT_SEARCHES_FOLDER="./data/recent_searches" RECENT_SEARCHES_FOLDER="./data/recent_searches"
RECENT_SEARCHES_LIMIT=20
VIDEO_FILES_FOLDERS='[ VIDEO_FILES_FOLDERS='[
"/home/user/videos", "/home/user/videos",
"/mnt/hdd/videos" "/mnt/hdd/videos"
]' ]'
VIDEO_FILES_EXT='["*.mkv", "*.mp4", "*.avi"]' VIDEO_FILES_EXT='[".mkv", ".mp4", ".avi"]'
PATTERNS_TO_IGNORE='["AOE1/"]' #file path patterns to ignore from video files scan
LINKOMANIJA_USERNAME=usern
LINKOMANIJA_PASSWORD=password
LINKOMANIJA_PASSKEY=some123long321passkey
TORRENT_DOWNLOAD_DIR='/mnt/hdd/videos'
TRANSMISSION_CLIENT_HOST=192.168.1.133
+23 -15
View File
@@ -5,36 +5,44 @@ export class CreateFilesTable1672729641013 implements MigrationInterface {
await queryRunner.query( await queryRunner.query(
`create table files `create table files
( (
id INTEGER id INTEGER
primary key autoincrement, primary key autoincrement,
path VARCHAR(512), path VARCHAR(512),
deleted TINYINT, relative_path TEXT,
size INTEGER, deleted TINYINT,
file_name VARCHAR(512), size INTEGER,
infos TEXT, file_name VARCHAR(512),
info TEXT, infos TEXT,
title_id INTEGER, info TEXT,
season INTEGER default NULL, title_id INTEGER,
duration REAL, season INTEGER default NULL,
transmission INTEGER default NULL, duration REAL,
lm INTEGER default NULL transmission_id INTEGER default NULL,
linkomanija INTEGER default NULL,
progress REAL,
stream_provider TEXT default 'fs' not null
);`, );`,
); );
await queryRunner.query( await queryRunner.query(
`create unique index path `create unique index files_path
on files (path);`, on files (path);`,
); );
await queryRunner.query( await queryRunner.query(
`create index season `create index files_season
on files (season);`, on files (season);`,
); );
await queryRunner.query( await queryRunner.query(
`create index title_id `create index files_title_id
on files (title_id);`, on files (title_id);`,
); );
await queryRunner.query(
`create index files_relative_path
on files (relative_path);`,
);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {
@@ -7,8 +7,11 @@ export class CreateLrtCategoriesTable1672730402752
await queryRunner.query( await queryRunner.query(
`create table lrt_categories `create table lrt_categories
( (
category_url TEXT, category_url VARCHAR(256),
category_id TEXT category_id INTEGER,
title VARCHAR(128),
last_access TIMESTAMP,
thumb VARCHAR(256)
);`, );`,
); );
@@ -16,6 +19,11 @@ export class CreateLrtCategoriesTable1672730402752
`create index lrt_categories_category_url_index `create index lrt_categories_category_url_index
on lrt_categories (category_url);`, on lrt_categories (category_url);`,
); );
await queryRunner.query(
`create index lrt_categories_category_id
on lrt_categories (category_id);`,
);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {
@@ -1,19 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddStreamProviderColumnToFilesTable1672820632908
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`alter table files
add stream_provider TEXT default 'fs' not null;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`alter table files
drop column stream_provider;`,
);
}
}
@@ -1,19 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRelativePathColumnToFilesTable1672990196772
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`alter table files
add relative_path TEXT;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`alter table files
drop column relative_path;`,
);
}
}
@@ -1,17 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameLinkomanijaColumnInFilesTable1673010469305
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE files RENAME COLUMN lm to linkomanija;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE files RENAME COLUMN linkomanija to lm;`,
);
}
}
@@ -1,19 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddWebtorrentColumnsToFilesTable1673010885172
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`alter table files
add webtorrent INTEGER;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`alter table files
drop column webtorrent;`,
);
}
}
@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTorrentsTableAndRelateToFiles1673603746273
implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`alter table files
add torrent_id INTEGER;`,
);
await queryRunner.query(
`create unique index file_torrent_id
on files (torrent_id);`,
);
await queryRunner.query(
`create table torrents
(
id INTEGER primary key,
magnet VARCHAR(512),
name VARCHAR(256),
path VARCHAR(512),
size INTEGER,
stopped TINYINT,
info_hash VARCHAR(64),
progress REAL,
transmission_id INTEGER,
info TEXT
);`,
);
await queryRunner.query(
`create unique index torrent_magnet
on torrents (magnet);`,
);
await queryRunner.query(
`create unique index torrent_transmission_id
on torrents (transmission_id);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`alter table files
drop column torrent_id;`,
);
await queryRunner.query(`DROP TABLE torrents`);
}
}
+3210 -35
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -3,7 +3,7 @@
"version": "0.0.1", "version": "0.0.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": false,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
"prebuild": "rimraf dist", "prebuild": "rimraf dist",
@@ -33,7 +33,7 @@
"@nestjs/platform-express": "^9.0.0", "@nestjs/platform-express": "^9.0.0",
"@nestjs/serve-static": "^3.0.0", "@nestjs/serve-static": "^3.0.0",
"@nestjs/typeorm": "^9.0.1", "@nestjs/typeorm": "^9.0.1",
"@types/fluent-ffmpeg": "^2.1.20", "cheerio": "^1.0.0-rc.12",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
@@ -42,16 +42,21 @@
"sqlite": "^4.1.2", "sqlite": "^4.1.2",
"sqlite3": "^5.1.2", "sqlite3": "^5.1.2",
"torrent-name-parser": "^0.6.5", "torrent-name-parser": "^0.6.5",
"typeorm": "^0.3.11" "transmission": "^0.4.10",
"typeorm": "^0.3.11",
"webtorrent": "^1.9.7"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^9.0.0", "@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0", "@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0", "@nestjs/testing": "^9.0.0",
"@types/cheerio": "^0.22.31",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/fluent-ffmpeg": "^2.1.20",
"@types/jest": "28.1.8", "@types/jest": "28.1.8",
"@types/node": "^16.0.0", "@types/node": "^16.0.0",
"@types/supertest": "^2.0.11", "@types/supertest": "^2.0.11",
"@types/webtorrent": "^0.109.3",
"@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0", "@typescript-eslint/parser": "^5.0.0",
"copyfiles": "^2.4.1", "copyfiles": "^2.4.1",
+14 -2
View File
@@ -2,7 +2,9 @@ import { Controller, Get, Head, Param, Req, Res } from '@nestjs/common';
import ApiResponse from '../Dto/api-response.dto'; import ApiResponse from '../Dto/api-response.dto';
import { AllFilesService } from './all-files.service'; import { AllFilesService } from './all-files.service';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { TitleTypeEnum } from '../../VideoFiles/Enum/title-type.enum'; import { TitleTypeEnum } from '../../Shared/Enum/title-type.enum';
import { KodiApiResponse } from '../Dto/kodi-api-response.type';
import NotificationResponse from '../Dto/notification-response.dto';
@Controller('api/all') @Controller('api/all')
export class AllFilesController { export class AllFilesController {
@@ -23,6 +25,16 @@ export class AllFilesController {
return this.allFilesService.getListOfTitles(TitleTypeEnum.movie); return this.allFilesService.getListOfTitles(TitleTypeEnum.movie);
} }
@Get('scan')
scanNewFiles(): KodiApiResponse {
return this.allFilesService.scanNewFiles();
}
@Get('delete/:fileId')
async deleteFile(@Param('fileId') fileId: string): Promise<KodiApiResponse> {
return this.allFilesService.deleteFile(fileId);
}
@Get('play/:fileId') @Get('play/:fileId')
@Head('play/:fileId') @Head('play/:fileId')
play( play(
@@ -37,7 +49,7 @@ export class AllFilesController {
getTitle( getTitle(
@Param('titleId') titleId: string, //actually a number, how to cast? @Param('titleId') titleId: string, //actually a number, how to cast?
@Param('seasonId') seasonId: string, //actually a number, how to cast? @Param('seasonId') seasonId: string, //actually a number, how to cast?
): Promise<ApiResponse> { ): Promise<KodiApiResponse> {
return this.allFilesService.loadTitle( return this.allFilesService.loadTitle(
parseInt(titleId), parseInt(titleId),
parseInt(seasonId), parseInt(seasonId),
+1
View File
@@ -9,5 +9,6 @@ import { VideoFilesModule } from '../../VideoFiles/video-files.module';
imports: [StreamerModule, VideoFilesModule], imports: [StreamerModule, VideoFilesModule],
controllers: [AllFilesController], controllers: [AllFilesController],
providers: [AllFilesService, KodiApiResponseFactory], providers: [AllFilesService, KodiApiResponseFactory],
exports: [AllFilesService],
}) })
export class AllFilesModule {} export class AllFilesModule {}
+92 -25
View File
@@ -4,9 +4,11 @@ import { KodiApiResponseFactory } from '../kodi-api-response.factory';
import { StreamerFacade } from '../../Streamer/streamer.facade'; import { StreamerFacade } from '../../Streamer/streamer.facade';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { VideoFilesFacade } from '../../VideoFiles/video-files.facade'; import { VideoFilesFacade } from '../../VideoFiles/video-files.facade';
import { TitleTypeEnum } from '../../VideoFiles/Enum/title-type.enum'; import { TitleTypeEnum } from '../../Shared/Enum/title-type.enum';
import { FileEntity } from '../../VideoFiles/Entity/file.entity'; import { FileEntity } from '../../Shared/Entity/file.entity';
import { TitleEntity } from '../../VideoFiles/Entity/title.entity'; import { TitleEntity } from '../../Shared/Entity/title.entity';
import { KodiApiResponse } from '../Dto/kodi-api-response.type';
import NotificationResponse from '../Dto/notification-response.dto';
@Injectable() @Injectable()
export class AllFilesService { export class AllFilesService {
@@ -17,19 +19,17 @@ export class AllFilesService {
) {} ) {}
getMenu(): ApiResponse { getMenu(): ApiResponse {
const apiResponse = this.koiApiResponseFactory.createApiResponse(); return this.koiApiResponseFactory
apiResponse .createApiResponse()
.createItem() .addNavigationItems(
.setLabel('Movies') [
.setPath('all/movie') ['Movies', 'movie'],
.setToFolder(); ['Shows', 'show'],
apiResponse ['Scan for new Files', 'scan'],
.createItem() ],
.setLabel('Shows') 'all/',
.setPath('all/show') )
.setToFolder(); .setNoSort();
return apiResponse;
} }
async getListOfTitles(type: TitleTypeEnum): Promise<ApiResponse> { async getListOfTitles(type: TitleTypeEnum): Promise<ApiResponse> {
@@ -50,16 +50,16 @@ export class AllFilesService {
return response; return response;
} }
async loadTitle(titleId: number, seasonId: number): Promise<ApiResponse> { async loadTitle(titleId: number, seasonId: number): Promise<KodiApiResponse> {
const titleEntity = titleId const titleEntity = titleId
? await this.videoFilesFacade.getTitleWithFiles(titleId) ? await this.videoFilesFacade.getTitleWithFiles(titleId)
: null; : null;
if (titleEntity === null) { if (titleEntity === null) {
//todo - alert instead of full api response return this.koiApiResponseFactory.createNotificationResponse(
return this.koiApiResponseFactory 'Not found' + (titleId ? ' ' + titleId : ''),
.createApiResponse() false,
.setTitle('Not found' + (titleId ? ' ' + titleId : '')); );
} }
if (titleEntity.type === TitleTypeEnum.movie) { if (titleEntity.type === TitleTypeEnum.movie) {
@@ -78,21 +78,33 @@ export class AllFilesService {
return this.buildSeasonsResponse(titleEntity); return this.buildSeasonsResponse(titleEntity);
} }
private buildFilesResponse( buildFilesResponse(
title: string, title: string,
fileEntities: Array<FileEntity>, fileEntities: Array<FileEntity>,
torrentContext = false,
): ApiResponse { ): ApiResponse {
const apiResponse = this.koiApiResponseFactory.createApiResponse(); const apiResponse = this.koiApiResponseFactory.createApiResponse();
apiResponse.setTitle(title); apiResponse.setTitle(title);
for (const fileEntity of fileEntities) { for (const fileEntity of fileEntities) {
apiResponse const item = apiResponse
.createItem() .createItem()
.setLabel(fileEntity.fileName) .setLabel(fileEntity.fileName)
.setPath('/api?path=all/play/' + fileEntity.id) .setPath('/api?path=all/play/' + fileEntity.id)
.setToPlayable() .setToPlayable()
.setPlot('Size ' + fileEntity.size) .setPlot(this.buildPlot(fileEntity))
.setSize(fileEntity.size) .setSize(fileEntity.size)
.setDuration(fileEntity.duration); .setDuration(fileEntity.duration);
if (!torrentContext) {
if (fileEntity.torrentId) {
item.addContextMenu(
'Show torrent',
'torr/torrent-context/' + fileEntity.torrentId,
);
} else {
item.addContextMenu('Delete file', 'all/delete/' + fileEntity.id);
}
}
} }
return apiResponse; return apiResponse;
@@ -102,7 +114,6 @@ export class AllFilesService {
const apiResponse = this.koiApiResponseFactory.createApiResponse(); const apiResponse = this.koiApiResponseFactory.createApiResponse();
apiResponse.setTitle(titleEntity.title); apiResponse.setTitle(titleEntity.title);
//todo - add num files count
const seasons = []; const seasons = [];
const filesCount = {}; const filesCount = {};
for (const fileEntity of titleEntity.files) { for (const fileEntity of titleEntity.files) {
@@ -144,4 +155,60 @@ export class AllFilesService {
return this.streamerFacade.streamVideoFile(request, response, fileEntity); return this.streamerFacade.streamVideoFile(request, response, fileEntity);
} }
getHRFileSize(size: number) {
// convert to human readable format.
const i: number = Math.floor(Math.log(size) / Math.log(1024));
const iPow: number = Math.pow(1024, i);
const sizePow: number = size / iPow;
return sizePow.toFixed(2) + ' ' + ['B', 'KB', 'MB', 'GB', 'TB'][i];
}
buildPlot(fileEntity: FileEntity): string {
const plot: string[] = [];
if (fileEntity.size) {
plot.push('Size ' + this.getHRFileSize(fileEntity.size));
}
const arrTorrProps: string[] = [];
if (fileEntity.linkomanija) {
arrTorrProps.push('LM');
}
if (fileEntity.transmissionId) {
arrTorrProps.push('transm');
}
if (fileEntity.torrentId) {
arrTorrProps.push('wt');
}
const torrProps = arrTorrProps.join(' + ');
if (torrProps) {
plot.push(torrProps);
}
if (fileEntity.progress) {
const progress = Math.round(fileEntity.progress * 1000) / 10;
const stopped = fileEntity.torrent.stopped ? ' Stopped' : '';
plot.push(`${progress}%${stopped}`);
}
return plot.join('\n');
}
public scanNewFiles(): NotificationResponse {
this.videoFilesFacade.updateFsVideoFiles();
return this.koiApiResponseFactory.createNotificationResponse(
'Scanning for new files and deleting old.',
false,
);
}
async deleteFile(fileId: string) {
const id = parseInt(fileId);
const result = await this.videoFilesFacade.deleteFile(id);
return new NotificationResponse(
result ? 'successfully deleted' : 'delete failed',
result,
);
}
} }
+23
View File
@@ -1,9 +1,11 @@
import ResponseItem from './response-item.dto'; import ResponseItem from './response-item.dto';
import { NavigationItem } from '../../Shared/Dto/navigation-item.dto';
export default class ApiResponse { export default class ApiResponse {
items: Array<ResponseItem> = []; items: Array<ResponseItem> = [];
content = 'videos'; content = 'videos';
updateList = true; updateList = true;
selectList: boolean | undefined;
category: string | undefined; category: string | undefined;
play: string | undefined; play: string | undefined;
msgBoxOK: string | undefined; msgBoxOK: string | undefined;
@@ -40,4 +42,25 @@ export default class ApiResponse {
return this; return this;
} }
setShowAsSelectList() {
this.updateList = false;
this.selectList = true;
return this;
}
addNavigationItems(navigationItems: NavigationItem[], pathPrefix = '') {
for (const [label, path, searchFor] of navigationItems) {
const item = this.createItem()
.setLabel(label)
.setToFolder()
.setPath(pathPrefix + path ?? '');
if (searchFor !== undefined) {
item.setActionSearch(searchFor);
}
}
return this;
}
} }
@@ -0,0 +1,4 @@
import ApiResponse from './api-response.dto';
import NotificationResponse from './notification-response.dto';
export type KodiApiResponse = ApiResponse | NotificationResponse;
+3 -2
View File
@@ -1,6 +1,7 @@
export default class NotificationResponse { export default class NotificationResponse {
notification: string; readonly notification: string;
refreshContainer: boolean; readonly refreshContainer: boolean;
readonly updateList = false;
constructor(message: string, refreshContainer = true) { constructor(message: string, refreshContainer = true) {
this.notification = message; this.notification = message;
@@ -4,10 +4,17 @@ import { firstValueFrom } from 'rxjs';
import { SearchResponseInterface } from '../Interface/search-response.interface'; import { SearchResponseInterface } from '../Interface/search-response.interface';
import { SearchResponseDto } from '../Dto/search-response.dto'; import { SearchResponseDto } from '../Dto/search-response.dto';
import { SearchResponseItemDto } from '../Dto/search-response-item.dto'; import { SearchResponseItemDto } from '../Dto/search-response-item.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { LrtCategory } from '../Entity/lrt-category.entity';
import { IsNull, Not, Repository } from 'typeorm';
@Injectable() @Injectable()
export class LrtApiCategoryClient { export class LrtApiCategoryClient {
constructor(private readonly httpService: HttpService) {} constructor(
private readonly httpService: HttpService,
@InjectRepository(LrtCategory)
private lrtCategoriesRepository: Repository<LrtCategory>,
) {}
async getCategory(catId: string): Promise<SearchResponseDto> { async getCategory(catId: string): Promise<SearchResponseDto> {
const url = 'https://www.lrt.lt/api/search?type=3&category_id=' + catId; const url = 'https://www.lrt.lt/api/search?type=3&category_id=' + catId;
@@ -36,6 +43,23 @@ export class LrtApiCategoryClient {
responseDto.addItem(itemDto); responseDto.addItem(itemDto);
} }
//update last access
this.lrtCategoriesRepository
.update({ categoryId: catId }, { lastAccess: Date.now() })
.then((r) => {
if (r.affected === 0) {
//todo - search-update category info
}
});
return responseDto; return responseDto;
} }
getRecentCategories(count: number): Promise<LrtCategory[]> {
return this.lrtCategoriesRepository.find({
order: { lastAccess: 'DESC' },
take: count,
where: { lastAccess: Not(IsNull()) },
});
}
} }
@@ -18,7 +18,8 @@ export class LrtApiSearchClient {
async searchCategories(query: string): Promise<SearchResponseDto> { async searchCategories(query: string): Promise<SearchResponseDto> {
query = query === 'viskas' ? '' : query; query = query === 'viskas' ? '' : query;
const url = 'https://www.lrt.lt/api/search?type=3&tema=' + query; //const url = 'https://www.lrt.lt/api/search?type=3&tema=' + query;
const url = 'https://www.lrt.lt/api/search?type=3&order=desc&tema=' + query;
//https://www.lrt.lt/api/search?page=1&count=44&order=desc //https://www.lrt.lt/api/search?page=1&count=44&order=desc
//https://www.lrt.lt/api/search?get_terms=1 //https://www.lrt.lt/api/search?get_terms=1
const resp = await firstValueFrom(this.httpService.get(url)); const resp = await firstValueFrom(this.httpService.get(url));
@@ -43,6 +44,18 @@ export class LrtApiSearchClient {
searchItem.img_path_prefix + searchItem.img_path_prefix +
'282x158' + '282x158' +
searchItem.img_path_postfix; searchItem.img_path_postfix;
let categoryId = await this.findCategoryIdInDb(searchItem.category_url);
if (categoryId === null) {
categoryId = await this.findCategoryIdInLrt(searchItem.category_url);
this.saveCategory(
searchItem.category_url,
categoryId,
searchItem.category_title,
itemDto.thumb,
);
}
itemDto.categoryId = await this.findCategoryId(searchItem.category_url); itemDto.categoryId = await this.findCategoryId(searchItem.category_url);
loadedCategories.add(searchItem.category_title); loadedCategories.add(searchItem.category_title);
@@ -60,10 +73,17 @@ export class LrtApiSearchClient {
return this.findCategoryIdInLrt(categoryUrl); return this.findCategoryIdInLrt(categoryUrl);
} }
private saveCategory(categoryUrl: string, catId: string) { private saveCategory(
categoryUrl: string,
catId: string,
catTitle: string,
thumb: string,
) {
const cat = new LrtCategory(); const cat = new LrtCategory();
cat.categoryUrl = categoryUrl; cat.categoryUrl = categoryUrl;
cat.categoryId = catId; cat.categoryId = catId;
cat.title = catTitle;
cat.thumb = thumb;
this.lrtCategoriesRepository.save(cat).then(); this.lrtCategoriesRepository.save(cat).then();
} }
@@ -91,9 +111,7 @@ export class LrtApiSearchClient {
const interim = body.indexOf('"', start); const interim = body.indexOf('"', start);
const end = body.indexOf('"', interim + 1); const end = body.indexOf('"', interim + 1);
if (end > interim) { if (end > interim) {
const catId = body.substring(interim + 1, end); return body.substring(interim + 1, end);
this.saveCategory(categoryUrl, catId);
return catId;
} else { } else {
throw 'unexpected response body 1'; throw 'unexpected response body 1';
} }
@@ -7,4 +7,13 @@ export class LrtCategory {
@Column({ name: 'category_id' }) @Column({ name: 'category_id' })
categoryId: string; categoryId: string;
@Column()
title: string;
@Column({ name: 'last_access' })
lastAccess: number;
@Column()
thumb: string;
} }
@@ -3,6 +3,7 @@ import { LrtApiSearchClient } from './Client/lrt-api-search.client';
import { SearchResponseDto } from './Dto/search-response.dto'; import { SearchResponseDto } from './Dto/search-response.dto';
import { LrtApiCategoryClient } from './Client/lrt-api-category.client'; import { LrtApiCategoryClient } from './Client/lrt-api-category.client';
import { LrtApiPlaylistClient } from './Client/lrt-api-playlist.client'; import { LrtApiPlaylistClient } from './Client/lrt-api-playlist.client';
import { LrtCategory } from './Entity/lrt-category.entity';
@Injectable() @Injectable()
export class LrtApiClient { export class LrtApiClient {
@@ -23,4 +24,8 @@ export class LrtApiClient {
getPlaylist(mediaUrl: string): Promise<string> { getPlaylist(mediaUrl: string): Promise<string> {
return this.playlistClient.getPlaylistUrl(mediaUrl); return this.playlistClient.getPlaylistUrl(mediaUrl);
} }
getRecentCategories(count: number): Promise<LrtCategory[]> {
return this.categoryClient.getRecentCategories(count);
}
} }
+1 -1
View File
@@ -12,7 +12,7 @@ export class LrtController {
) {} ) {}
@Get() @Get()
getLrtMenu(): ApiResponse { getLrtMenu(): Promise<ApiResponse> {
return this.lrtService.getMainMenu(); return this.lrtService.getMainMenu();
} }
+3 -3
View File
@@ -3,11 +3,11 @@ import { LrtController } from './lrt.controller';
import { LrtService } from './lrt.service'; import { LrtService } from './lrt.service';
import { KodiApiResponseFactory } from '../kodi-api-response.factory'; import { KodiApiResponseFactory } from '../kodi-api-response.factory';
import { LrtApiClientModule } from './LrtApiClient/lrt-api-client.module'; import { LrtApiClientModule } from './LrtApiClient/lrt-api-client.module';
import { RecentSearchesService } from '../RecentSearches/recent-searches.service'; import { RecentSearchesModule } from '../RecentSearches/recent-searches.module';
@Module({ @Module({
imports: [LrtApiClientModule], imports: [LrtApiClientModule, RecentSearchesModule],
controllers: [LrtController], controllers: [LrtController],
providers: [KodiApiResponseFactory, LrtService, RecentSearchesService], providers: [KodiApiResponseFactory, LrtService],
}) })
export class LrtModule {} export class LrtModule {}
+21 -21
View File
@@ -12,34 +12,34 @@ export class LrtService {
private readonly recentSearchesService: RecentSearchesService, private readonly recentSearchesService: RecentSearchesService,
) {} ) {}
getMainMenu(): ApiResponse { getMainMenu(): Promise<ApiResponse> {
const apiResponse = this.kodiApiResponseFactory.createApiResponse(); const apiResponse = this.kodiApiResponseFactory.createApiResponse();
apiResponse.setNoSort().setTitle('LRT.lt'); apiResponse.setNoSort().setTitle('LRT.lt');
this.createMainMenu(apiResponse);
return apiResponse; return this.createMainMenu(apiResponse);
} }
private createMainMenu(apiResponse: ApiResponse): void { private async createMainMenu(apiResponse: ApiResponse): Promise<ApiResponse> {
//todo - refactor to 5 most recent searches + viskas? apiResponse.addNavigationItems(
const items = { [
'lrt/recent': 'neseniai ieskoti', ['paieška', 'search', ''],
'lrt/tema/vaikams': 'vaikams', ['neseniai ieskoti', 'recent'],
'lrt/tema/sportas': 'sportas', ['viskas', 'tema/viskas'],
'lrt/tema/kultura': 'kultura', ],
'lrt/tema/muzika': 'muzika', 'lrt/',
'lrt/tema/viskas': 'viskas', );
'lrt/tema/tv-laidos': 'tv-laidos',
};
apiResponse
.createItem()
.setPath('lrt/search')
.setActionSearch()
.setLabel('paieška');
for (const [path, label] of Object.entries(items)) { const recentCategories = await this.lrtApiClient.getRecentCategories(7);
apiResponse.createItem().setLabel(label).setToFolder().setPath(path); for (const category of recentCategories) {
apiResponse
.createItem()
.setPath('/lrt/cat/' + category.categoryId)
.setThumb(category.thumb)
.setLabel(category.title)
.setToFolder();
} }
return apiResponse;
} }
async searchCategories(query: string): Promise<ApiResponse> { async searchCategories(query: string): Promise<ApiResponse> {
@@ -4,16 +4,21 @@ import { configService, ConfigService } from '../../config/config.service';
import { KodiApiResponseFactory } from '../kodi-api-response.factory'; import { KodiApiResponseFactory } from '../kodi-api-response.factory';
import { join } from 'path'; import { join } from 'path';
import { readFile, writeFile } from 'fs/promises'; import { readFile, writeFile } from 'fs/promises';
import { RecentSearchesConfig } from '../../config/recent-searches.config';
@Injectable() @Injectable()
export class RecentSearchesService { export class RecentSearchesService {
private readonly configService: ConfigService; private readonly config: RecentSearchesConfig;
constructor(private readonly kodiApiResponseFactory: KodiApiResponseFactory) { constructor(private readonly kodiApiResponseFactory: KodiApiResponseFactory) {
this.configService = configService; this.config = configService.getRecentSearchesConfig();
} }
async getRecentSearches(module: string, path: string): Promise<ApiResponse> { async getRecentSearches(module: string, path: string): Promise<ApiResponse> {
const data = await this.getRecentSearchesData(module); const data = await this.getRecentSearchesData(module);
const response = this.kodiApiResponseFactory.createApiResponse(); const response = this.kodiApiResponseFactory
.createApiResponse()
.setNoSort();
for (const recentSearch of data) { for (const recentSearch of data) {
response response
.createItem() .createItem()
@@ -28,14 +33,19 @@ export class RecentSearchesService {
async addRecentSearch(module: string, term: string): Promise<void> { async addRecentSearch(module: string, term: string): Promise<void> {
const data = await this.getRecentSearchesData(module); const data = await this.getRecentSearchesData(module);
const index = data.indexOf(term); const index = data.indexOf(term);
if (index > -1) { if (index === 0) {
return Promise.resolve();
}
if (index > 0) {
data.splice(index, 1); data.splice(index, 1);
} }
data.push(term); data.unshift(term);
const rawData = JSON.stringify(data);
const filePath = this.getFilePath(module);
await writeFile(filePath, rawData); const rawData = JSON.stringify(
data.slice(0, this.config.getRecentSearchesLimit()),
);
const filePath = this.getFilePath(module);
writeFile(filePath, rawData).then();
} }
private async getRecentSearchesData(module: string): Promise<Array<string>> { private async getRecentSearchesData(module: string): Promise<Array<string>> {
@@ -46,9 +56,6 @@ export class RecentSearchesService {
} }
private getFilePath(module: string): string { private getFilePath(module: string): string {
return join( return join(this.config.getRecentSearchesFolder(), module + '.json');
this.configService.getPaths().getRecentSearchesFolder(),
module + '.json',
);
} }
} }
+86
View File
@@ -0,0 +1,86 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import ApiResponse from '../Dto/api-response.dto';
import { TorrentService } from './torrent.service';
@Controller('api/torr')
export class TorrentController {
constructor(private readonly torrService: TorrentService) {}
@Get()
async getMenu(): Promise<ApiResponse> {
return this.torrService.getMenu();
}
@Get('torrs')
async getTorrents(): Promise<ApiResponse> {
return this.torrService.getTorrents();
}
@Get('torr/:id')
async getTorrFiles(@Param('id') id: string): Promise<ApiResponse> {
return this.torrService.getTorrentFiles(id);
}
@Get('torrent/:id')
async getOneTorrentAsList(@Param('id') id: string) {
return this.torrService.getOneTorrentAsList(id);
}
@Get('torrent-context/:id')
async getOneTorrentAsListFromContextMenu(@Param('id') id: string) {
return this.torrService.getOneTorrentAsListFromContextMenu(id);
}
@Get('search/:provider')
getSearch(
@Query('search') query: string,
@Param('provider') provider: string,
) {
return this.torrService.search(query, provider);
}
@Get('recent/:provider')
getRecent(@Param('provider') provider: string) {
return this.torrService.getRecentSearches(provider);
}
@Get('add/:torrId')
add(@Param('torrId') torrId: string) {
return this.torrService.add(torrId);
}
@Get('custom-menu/:provider')
async getCustomMenu(@Param() params: string[]) {
return this.torrService.getCustomMenu(params['provider'], '');
}
@Get('custom-menu/:provider/*')
async getCustomMenuAction(@Param() params: string[]) {
return this.torrService.getCustomMenu(params['provider'], params[0]);
}
@Get('delete/:id')
deleteTorrent(@Param('id') id: string) {
return this.torrService.deleteTorrent(id);
}
@Get('resume/:id')
async resumeTorrent(@Param('id') id: string) {
return this.torrService.resumeTorrent(id);
}
@Get('stop/:id')
async stopTorrent(@Param('id') id: string) {
return this.torrService.stopTorrent(id);
}
@Get('stop-transmission/:id')
async stopTransmissionTorrent(@Param('id') id: string) {
return this.torrService.stopTransmissionTorrent(id);
}
@Get('add-transmission/:id')
async addTransmissionTorrent(@Param('id') id: string) {
return this.torrService.addTransmissionTorrent(id);
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TorrentService } from './torrent.service';
import { TorrentController } from './torrent.controller';
import { KodiApiResponseFactory } from '../kodi-api-response.factory';
import { SearchClientModule } from '../../Torrent/SearchClient/search-client.module';
import { RecentSearchesModule } from '../RecentSearches/recent-searches.module';
import { TorrentDownloadClientModule } from '../../Torrent/DownloadClient/torrent-download-client.module';
import { AllFilesModule } from '../AllFiles/all-files.module';
import { TorrentSeedClientModule } from '../../Torrent/SeedClient/torrent-seed-client.module';
@Module({
imports: [
SearchClientModule,
RecentSearchesModule,
TorrentDownloadClientModule,
AllFilesModule,
TorrentSeedClientModule,
],
controllers: [TorrentController],
providers: [TorrentService, KodiApiResponseFactory],
})
export class TorrentModule {}
+344
View File
@@ -0,0 +1,344 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { KodiApiResponseFactory } from '../kodi-api-response.factory';
import ApiResponse from '../Dto/api-response.dto';
import { SearchClientFacade } from '../../Torrent/SearchClient/search-client.facade';
import { TorrentSearchResult } from '../../Torrent/Dto/torrent-search-result.class';
import { RecentSearchesService } from '../RecentSearches/recent-searches.service';
import { TorrentSearchClientResponse } from '../../Torrent/Dto/torrent-search-client-response.dto';
import { TorrentDownloadClient } from '../../Torrent/DownloadClient/torrent-download.client';
import { AllFilesService } from '../AllFiles/all-files.service';
import NotificationResponse from '../Dto/notification-response.dto';
import { TorrentEntity } from '../../Shared/Entity/torrent.entity';
import { TorrentSeedClient } from '../../Torrent/SeedClient/torrent-seed.client';
@Injectable()
export class TorrentService {
private torrentsCache: Record<string, TorrentSearchResult> = {}; //no need for all that info, magnet is enough
constructor(
private readonly koiApiResponseFactory: KodiApiResponseFactory,
private readonly torrSearchClient: SearchClientFacade,
private readonly recentSearchesService: RecentSearchesService,
private readonly torrDownloadClient: TorrentDownloadClient,
private readonly torrSeedClient: TorrentSeedClient,
private readonly allFilesService: AllFilesService,
) {}
async getMenu(): Promise<ApiResponse> {
const apiResponse = this.koiApiResponseFactory
.createApiResponse()
.setNoSort();
const searchClientNames = this.torrSearchClient.getSearchClientNames();
apiResponse.addNavigationItems([['Torrs', 'torrs']], 'torr/');
apiResponse.setTitle('Torrent service');
for (const clientName of searchClientNames) {
apiResponse.addNavigationItems(
[
[clientName + ' Search', 'search/' + clientName, ''],
[clientName + ' Neseniai ieškoti', 'recent/' + clientName],
],
'torr/',
);
const customMenu = await this.torrSearchClient.getCustomMenu(clientName);
apiResponse.addNavigationItems(
customMenu.navigationItems,
'torr/custom-menu/' + clientName + '/',
);
this.addTorrentSearchResultsToApiResponse(
apiResponse,
customMenu.searchResults,
);
}
return apiResponse;
}
async getCustomMenu(provider: string, path: string) {
const apiResponse = this.koiApiResponseFactory.createApiResponse();
apiResponse.setNoSort();
const customMenu: TorrentSearchClientResponse | false =
await this.torrSearchClient
.getCustomMenu(provider, path)
.catch(() => false);
if (customMenu === false) {
return this.koiApiResponseFactory.createNotificationResponse(
'Error while searching',
false,
);
}
apiResponse.setTitle(customMenu.title);
apiResponse.addNavigationItems(
customMenu.navigationItems,
'torr/custom-menu/' + provider + '/',
);
this.addTorrentSearchResultsToApiResponse(
apiResponse,
customMenu.searchResults,
);
return apiResponse;
}
async search(
query: string,
provider: string,
): Promise<ApiResponse | NotificationResponse> {
this.recentSearchesService.addRecentSearch('torr', query).then(/*ignore*/);
const searchResults: TorrentSearchClientResponse | false =
await this.torrSearchClient.search(query, provider).catch(() => false);
if (searchResults === false) {
return this.koiApiResponseFactory.createNotificationResponse(
'Error while searching',
false,
);
}
const apiResponse = this.koiApiResponseFactory.createApiResponse();
apiResponse.setNoSort();
this.addTorrentSearchResultsToApiResponse(
apiResponse,
searchResults.searchResults,
);
return apiResponse;
}
async getRecentSearches(provider: string): Promise<ApiResponse> {
return this.recentSearchesService.getRecentSearches(
'torr',
'torr/search/' + provider,
);
}
async add(torrId: string): Promise<ApiResponse | NotificationResponse> {
const torrSearchData = this.torrentsCache[torrId];
if (torrSearchData === undefined) {
throw new NotFoundException('torrent search data not found');
}
const fileEntities = await this.torrDownloadClient
.addTorrent(torrSearchData.magnet)
.catch(() => null);
if (fileEntities === null) {
return this.koiApiResponseFactory.createNotificationResponse(
'error adding torrent',
false,
);
}
return this.allFilesService.buildFilesResponse(
torrSearchData.name,
fileEntities,
true,
);
}
private addTorrentSearchResultsToApiResponse(
apiResponse: ApiResponse,
searchResults: TorrentSearchResult[],
): void {
for (const searchResult of searchResults) {
this.torrentsCache[searchResult.id] = searchResult;
apiResponse
.createItem()
.setDate(searchResult.date)
.setLabel(searchResult.name)
.setToFolder()
.setPath('torr/add/' + searchResult.id)
.setPlot(
[
searchResult.date,
searchResult.size,
'S' + searchResult.seeders + ' L' + searchResult.leechers,
searchResult.info,
].join('\n'),
);
}
}
async getTorrents(): Promise<ApiResponse> {
const torrentEntities = await this.torrDownloadClient.getTorrents();
const apiResponse = this.koiApiResponseFactory.createApiResponse();
apiResponse.setTitle('Torrents');
for (const torrentEntity of torrentEntities) {
this.addTorrentToApiResponse(torrentEntity, apiResponse);
}
return apiResponse;
}
private addTorrentToApiResponse(
torrentEntity: TorrentEntity,
apiResponse: ApiResponse,
): void {
const firstFileEntity = torrentEntity.files[0];
if (!firstFileEntity) {
return;
}
firstFileEntity.progress = torrentEntity.progress;
firstFileEntity.size = torrentEntity.size;
const item = apiResponse
.createItem()
.setPlot(this.buildPlot(torrentEntity))
.setLabel(torrentEntity.name)
.setPath('/torr/torr/' + torrentEntity.id)
.setToFolder();
if (torrentEntity.stopped && torrentEntity.progress !== 1) {
item.addContextMenu('resume wt', '/torr/resume/' + torrentEntity.id);
}
if (!torrentEntity.stopped) {
item.addContextMenu('stop wt', '/torr/stop/' + torrentEntity.id);
}
if (torrentEntity.transmissionId) {
item.addContextMenu(
'stop transmission',
'/torr/stop-transmission/' + torrentEntity.id,
);
}
if (!torrentEntity.transmissionId && torrentEntity.progress === 1) {
item.addContextMenu(
'add transmission',
'/torr/add-transmission/' + torrentEntity.id,
);
}
item.addContextMenu('delete', '/torr/delete/' + torrentEntity.id);
}
buildPlot(torrentEntity: TorrentEntity): string {
const plot: string[] = [];
if (torrentEntity.size) {
plot.push(
'Size ' + this.allFilesService.getHRFileSize(torrentEntity.size),
);
}
const arrTorrProps: string[] = [];
if (torrentEntity.linkomanija) {
arrTorrProps.push('LM');
}
if (torrentEntity.transmissionId) {
arrTorrProps.push('transm');
}
arrTorrProps.push('wt');
const torrProps = arrTorrProps.join(' + ');
if (torrProps) {
plot.push(torrProps);
}
if (torrentEntity.progress) {
const progress = Math.round(torrentEntity.progress * 1000) / 10;
const stopped = torrentEntity.stopped ? ' Stopped' : '';
plot.push(`${progress}%${stopped}`);
}
return plot.join('\n');
}
getOneTorrentAsListFromContextMenu(torrentId: string) {
//by default context menu does not update container (view), we need to force it
return {
forceUpdate: {
urlParams: {
action: 'query',
path: 'torr/torrent/' + torrentId,
},
},
};
}
async getOneTorrentAsList(torrentId: string): Promise<ApiResponse> {
const id = parseInt(torrentId);
const torrentEntity = await this.torrDownloadClient.getTorrentById(id);
const apiResponse = this.koiApiResponseFactory.createApiResponse();
if (torrentEntity) {
this.addTorrentToApiResponse(torrentEntity, apiResponse);
}
return apiResponse;
}
async getTorrentFiles(id: string): Promise<ApiResponse> {
const torrentEntity = await this.torrDownloadClient.getTorrentById(
parseInt(id),
);
return this.allFilesService.buildFilesResponse(
torrentEntity.name,
torrentEntity.files,
true,
);
}
async stopTorrent(id: string) {
const torrId = parseInt(id);
try {
await this.torrDownloadClient.stopTorrent(torrId);
} catch (e) {
return this.koiApiResponseFactory.createNotificationResponse(
'error stopping',
);
}
return this.koiApiResponseFactory.createNotificationResponse('stopped');
}
async resumeTorrent(id: string) {
const torrId = parseInt(id);
try {
await this.torrDownloadClient.resumeTorrent(torrId);
} catch (e) {
return this.koiApiResponseFactory.createNotificationResponse(
'error resuming',
);
}
return this.koiApiResponseFactory.createNotificationResponse('resumed');
}
async stopTransmissionTorrent(id: string) {
const torrId = parseInt(id);
const result = await this.torrSeedClient
.removeTorrent(torrId)
.catch(() => false);
return new NotificationResponse(
result ? 'torrent removed from transmission' : 'removal failed',
true,
);
}
async addTransmissionTorrent(id: string) {
const torrId = parseInt(id);
const result = await this.torrSeedClient
.addTorrentById(torrId)
.catch(() => false);
return new NotificationResponse(
result ? 'torrent added to transmission' : 'adding failed',
true,
);
}
async deleteTorrent(id: string) {
const torrId = parseInt(id);
const result = await this.torrSeedClient
.removeTorrent(torrId, true)
.catch(() => false);
return new NotificationResponse(
result ? 'torrent deleted' : 'deleting from wt not implemented',
true,
);
}
}
@@ -5,12 +5,12 @@ import { join } from 'path';
export class KodiApiUrlRewriteMiddleware implements NestMiddleware { export class KodiApiUrlRewriteMiddleware implements NestMiddleware {
async use(req: Request, res: Response, next: NextFunction) { async use(req: Request, res: Response, next: NextFunction) {
let path = '';
if (req.query && req.query.path) { if (req.query && req.query.path) {
let path = '';
path = req.query.path as string; path = req.query.path as string;
const query = req.url.split('?').pop();
req.url = join('/api/', path) + (query ? '?' + query : '');
} }
const query = req.url.split('?').pop();
req.url = join('/api/', path) + (query ? '?' + query : '');
next(); next();
} }
+5
View File
@@ -8,4 +8,9 @@ export class KodiApiController {
getMainMenu() { getMainMenu() {
return this.kodiApiService.getMainMenu(); return this.kodiApiService.getMainMenu();
} }
@Get('test')
getCurrentTest() {
return this.kodiApiService.getCurrentTest();
}
} }
+2 -1
View File
@@ -10,9 +10,10 @@ import { LrtModule } from './LRT/lrt.module';
import { KodiApiService } from './kodi-api.service'; import { KodiApiService } from './kodi-api.service';
import { KodiApiResponseFactory } from './kodi-api-response.factory'; import { KodiApiResponseFactory } from './kodi-api-response.factory';
import { AllFilesModule } from './AllFiles/all-files.module'; import { AllFilesModule } from './AllFiles/all-files.module';
import { TorrentModule } from './Torrent/torrent.module';
@Module({ @Module({
imports: [LrtModule, AllFilesModule], imports: [LrtModule, AllFilesModule, TorrentModule],
controllers: [KodiApiController], controllers: [KodiApiController],
providers: [KodiApiService, KodiApiResponseFactory], providers: [KodiApiService, KodiApiResponseFactory],
}) })
+20 -16
View File
@@ -9,23 +9,27 @@ export class KodiApiService {
) {} ) {}
getMainMenu(): ApiResponse { getMainMenu(): ApiResponse {
const apiResponse = this.kodiApiResponseFactory.createApiResponse(); return this.kodiApiResponseFactory
.createApiResponse()
apiResponse.setTitle('Menu'); .setTitle('Menu')
this.createMainMenu(apiResponse); .addNavigationItems([
['All Files', 'all'],
return apiResponse; ['Torr', 'torr'],
['LRT.lt', 'lrt'],
//['Test', 'test'],
]);
} }
private createMainMenu(apiResponse: ApiResponse): void { getCurrentTest() {
const items = { return this.kodiApiResponseFactory
all: 'All files', .createApiResponse()
torr: 'Torr', .setTitle('Menu')
lrt: 'LRT.lt', .addNavigationItems([
}; ['select 1', 'all'],
['select 2', 'lrt'],
for (const [path, label] of Object.entries(items)) { ['select 3', 'torr'],
apiResponse.createItem().setLabel(label).setToFolder().setPath(path); ['select 4 search', 'test', ''],
} ])
.setShowAsSelectList();
} }
} }
+1
View File
@@ -8,6 +8,7 @@ export class RequestLogMiddleware implements NestMiddleware {
async use(req: Request, res: Response, next: NextFunction) { async use(req: Request, res: Response, next: NextFunction) {
const strIP = this.extractIp(req); const strIP = this.extractIp(req);
this.log(strIP + ' ' + req.method + ' ' + decodeURI(req.url)); this.log(strIP + ' ' + req.method + ' ' + decodeURI(req.url));
console.log(decodeURI(req.url));
next(); next();
} }
+1
View File
@@ -0,0 +1 @@
export type NavigationItem = [label: string, path?: string, searchFor?: string];
@@ -1,6 +1,7 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
import { TitleEntity } from './title.entity'; import { TitleEntity } from './title.entity';
import { StreamProviderEnum } from '../../Streamer/ReadStreamProvider/stream-provider.enum'; import { StreamProviderEnum } from '../Enum/stream-provider.enum';
import { TorrentEntity } from './torrent.entity';
@Entity('files') @Entity('files')
export class FileEntity { export class FileEntity {
@@ -44,13 +45,19 @@ export class FileEntity {
@Column({ name: 'relative_path' }) @Column({ name: 'relative_path' })
relativePath: string; relativePath: string;
//todo - consider if these 3 below should be better organized @Column({ name: 'transmission_id' })
@Column() transmissionId: number;
transmission: boolean;
@Column() @Column()
linkomanija: boolean; linkomanija: boolean;
@Column() @Column({ name: 'torrent_id' })
webtorrent: boolean; torrentId: number;
@ManyToOne(() => TorrentEntity, (torrent) => torrent.files)
@JoinColumn({ name: 'torrent_id' })
torrent: TorrentEntity;
@Column({ type: 'float' })
progress: number;
} }
+42
View File
@@ -0,0 +1,42 @@
import { Column, Entity, OneToMany, PrimaryColumn } from 'typeorm';
import { FileEntity } from './file.entity';
@Entity('torrents')
export class TorrentEntity {
@PrimaryColumn()
id: number;
@Column()
magnet: string;
@Column()
name: string;
@Column()
path: string;
@Column()
stopped: boolean;
@Column({ name: 'info_hash' })
infoHash: string;
@Column({ type: 'float' })
progress: number;
@OneToMany(() => FileEntity, (file) => file.torrent)
files: FileEntity[];
@Column()
size: number;
@Column({ name: 'transmission_id' })
transmissionId: number;
//todo - consider changing to a must-seed property for other private trackers (trl, etc)
@Column()
linkomanija: boolean;
@Column()
info: string;
}
@@ -1,4 +1,4 @@
import { FileEntity } from '../../../VideoFiles/Entity/file.entity'; import { FileEntity } from '../../../Shared/Entity/file.entity';
import { ReadStreamCreatable } from '../../Interface/read-stream-creatable.interface'; import { ReadStreamCreatable } from '../../Interface/read-stream-creatable.interface';
import { MimeService } from '../../Mime/mime.service'; import { MimeService } from '../../Mime/mime.service';
import { Stats } from 'fs'; import { Stats } from 'fs';
@@ -6,13 +6,17 @@ import { stat } from 'fs/promises';
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { ReadStreamCreatableFile } from './read-stream-creatable-file.dto'; import { ReadStreamCreatableFile } from './read-stream-creatable-file.dto';
import { ReadStreamCreatableProviderInterface } from '../read-stream-creatable-provider.interface'; import { ReadStreamCreatableProviderInterface } from '../read-stream-creatable-provider.interface';
import { StreamProviderEnum } from '../stream-provider.enum'; import { StreamProviderEnum } from '../../../Shared/Enum/stream-provider.enum';
import { VideoFilesFacade } from '../../../VideoFiles/video-files.facade';
@Injectable() @Injectable()
export class FsReadStreamCreatableProvider export class FsReadStreamCreatableProvider
implements ReadStreamCreatableProviderInterface implements ReadStreamCreatableProviderInterface
{ {
constructor(private readonly mimeService: MimeService) {} constructor(
private readonly mimeService: MimeService,
private readonly videoFilesService: VideoFilesFacade,
) {}
supports(fileEntity: FileEntity): boolean { supports(fileEntity: FileEntity): boolean {
return fileEntity.streamProvider === StreamProviderEnum.fs; return fileEntity.streamProvider === StreamProviderEnum.fs;
@@ -24,7 +28,7 @@ export class FsReadStreamCreatableProvider
const filePath = fileEntity.path; const filePath = fileEntity.path;
const stats: Stats | false = await stat(filePath).catch(() => false); const stats: Stats | false = await stat(filePath).catch(() => false);
if (stats === false) { if (stats === false) {
//todo - set the file to deleted, since no longer available. Events? this.videoFilesService.updateFsVideoFiles();
throw new NotFoundException('file not found'); throw new NotFoundException('file not found');
} }
const mimeType = this.mimeService.getMime(filePath); const mimeType = this.mimeService.getMime(filePath);
@@ -0,0 +1,24 @@
import { ReadStream } from 'fs';
import { ReadStreamOptions } from '../../Interface/read-stream-options.interface';
import { ReadStreamCreatable } from '../../Interface/read-stream-creatable.interface';
import { Injectable } from '@nestjs/common';
import { TorrentFile } from 'webtorrent';
@Injectable()
export class ReadStreamCreatableTorrentFile implements ReadStreamCreatable {
constructor(
private readonly torrentFile: TorrentFile,
readonly length: number,
readonly mimeType: string,
) {
this.torrentFile = torrentFile;
this.length = length;
this.mimeType = mimeType;
}
createReadStream(options: ReadStreamOptions): ReadStream {
return this.torrentFile.createReadStream(
options as { start: number; end: number },
) as ReadStream;
}
}
@@ -0,0 +1,42 @@
import { FileEntity } from '../../../Shared/Entity/file.entity';
import { Injectable, NotFoundException } from '@nestjs/common';
import { MimeService } from '../../Mime/mime.service';
import { ReadStreamCreatableProviderInterface } from '../read-stream-creatable-provider.interface';
import { TorrentDownloadClient } from '../../../Torrent/DownloadClient/torrent-download.client';
import { StreamProviderEnum } from '../../../Shared/Enum/stream-provider.enum';
import { ReadStreamCreatable } from '../../Interface/read-stream-creatable.interface';
import { ReadStreamCreatableTorrentFile } from './read-stream-creatable-torrent-file.dto';
@Injectable()
export class WtReadStreamCreatableProvider
implements ReadStreamCreatableProviderInterface
{
constructor(
private readonly mimeService: MimeService,
private readonly torrDlClient: TorrentDownloadClient,
) {}
supports(fileEntity: FileEntity): boolean {
return fileEntity.streamProvider === StreamProviderEnum.wt;
}
async getReadStreamCreatable(
fileEntity: FileEntity,
): Promise<ReadStreamCreatable> {
if (!fileEntity.torrent) {
throw new NotFoundException('torrent not found');
}
const torrentFile = await this.torrDlClient.getTorrentFile(fileEntity);
if (torrentFile === null) {
//todo - rescan files, probably moved to fs provider. Events?
throw new NotFoundException('torrent file not found');
}
const mimeType = this.mimeService.getMime(fileEntity.relativePath);
return new ReadStreamCreatableTorrentFile(
torrentFile,
torrentFile.length,
mimeType,
);
}
}
@@ -1,4 +1,4 @@
import { FileEntity } from '../../VideoFiles/Entity/file.entity'; import { FileEntity } from '../../Shared/Entity/file.entity';
import { ReadStreamCreatable } from '../Interface/read-stream-creatable.interface'; import { ReadStreamCreatable } from '../Interface/read-stream-creatable.interface';
export interface ReadStreamCreatableProviderInterface { export interface ReadStreamCreatableProviderInterface {
@@ -1,5 +1,5 @@
import { ReadStreamCreatable } from '../Interface/read-stream-creatable.interface'; import { ReadStreamCreatable } from '../Interface/read-stream-creatable.interface';
import { FileEntity } from '../../VideoFiles/Entity/file.entity'; import { FileEntity } from '../../Shared/Entity/file.entity';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ReadStreamCreatableProviderInterface } from './read-stream-creatable-provider.interface'; import { ReadStreamCreatableProviderInterface } from './read-stream-creatable-provider.interface';
@@ -17,5 +17,6 @@ export class ReadStreamCreatableProvider {
return provider.getReadStreamCreatable(fileEntity); return provider.getReadStreamCreatable(fileEntity);
} }
} }
return Promise.reject('no stream provider');
} }
} }
@@ -17,5 +17,8 @@ export class ResponseSenderService {
} }
readable.pipe(response); readable.pipe(response);
readable.on('error', (e) => {
//todo - figure out the Error: Writable stream closed prematurely
});
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { FileEntity } from '../VideoFiles/Entity/file.entity'; import { FileEntity } from '../Shared/Entity/file.entity';
import { ReadStreamCreatableProvider } from './ReadStreamProvider/read-stream-creatable.provider'; import { ReadStreamCreatableProvider } from './ReadStreamProvider/read-stream-creatable.provider';
import { StreamerService } from './StreamerService/streamer.service'; import { StreamerService } from './StreamerService/streamer.service';
+6 -2
View File
@@ -10,9 +10,12 @@ import {
ReadStreamCreatableProviders, ReadStreamCreatableProviders,
} from './ReadStreamProvider/read-stream-creatable.provider'; } from './ReadStreamProvider/read-stream-creatable.provider';
import { FsReadStreamCreatableProvider } from './ReadStreamProvider/FileSystem/fs-read-stream-creatable.provider'; import { FsReadStreamCreatableProvider } from './ReadStreamProvider/FileSystem/fs-read-stream-creatable.provider';
import { WtReadStreamCreatableProvider } from './ReadStreamProvider/Webtorrent/wt-read-stream-creatable.provider';
import { TorrentDownloadClientModule } from '../Torrent/DownloadClient/torrent-download-client.module';
import { VideoFilesModule } from '../VideoFiles/video-files.module';
@Module({ @Module({
imports: [], imports: [TorrentDownloadClientModule, VideoFilesModule],
providers: [ providers: [
MimeService, MimeService,
RequestRangeService, RequestRangeService,
@@ -22,10 +25,11 @@ import { FsReadStreamCreatableProvider } from './ReadStreamProvider/FileSystem/f
StreamerFacade, StreamerFacade,
ReadStreamCreatableProvider, ReadStreamCreatableProvider,
FsReadStreamCreatableProvider, FsReadStreamCreatableProvider,
WtReadStreamCreatableProvider,
{ {
provide: ReadStreamCreatableProviders, provide: ReadStreamCreatableProviders,
useFactory: (...providers) => providers, useFactory: (...providers) => providers,
inject: [FsReadStreamCreatableProvider], inject: [FsReadStreamCreatableProvider, WtReadStreamCreatableProvider],
}, },
], ],
exports: [StreamerFacade], exports: [StreamerFacade],
@@ -0,0 +1,299 @@
import { Injectable } from '@nestjs/common';
import * as WebTorrent from 'webtorrent';
import { Instance, Torrent, TorrentFile, TorrentOptions } from 'webtorrent';
import { InjectRepository } from '@nestjs/typeorm';
import { TorrentEntity } from '../../../Shared/Entity/torrent.entity';
import { Repository } from 'typeorm';
import { configService } from '../../../config/config.service';
import { extname, join } from 'path';
import { FileEntity } from '../../../Shared/Entity/file.entity';
import { StreamProviderEnum } from '../../../Shared/Enum/stream-provider.enum';
import { VideoFilesFacade } from '../../../VideoFiles/video-files.facade';
import { TorrentSeedClient } from '../../SeedClient/torrent-seed.client';
@Injectable()
export class WebtorrentClient {
private client: Instance;
private readonly torrentOptions: TorrentOptions;
private readonly configService = configService.getVideoFilesConfig();
constructor(
@InjectRepository(TorrentEntity)
private readonly torrentRepository: Repository<TorrentEntity>,
private readonly videoFilesFacade: VideoFilesFacade,
@InjectRepository(FileEntity)
private readonly filesRepository: Repository<FileEntity>,
private readonly seedClient: TorrentSeedClient,
) {
this.client = new WebTorrent();
this.torrentOptions = {};
this.torrentOptions.path = this.configService.getTorrentDownloadDir();
this.torrentOptions.getAnnounceOpts = function() {
const that = this as Torrent;
return {
uploaded: 0, //this.uploaded,
downloaded: 0, //this.downloaded,
left: that.length, //Math.max(this.length - this.downloaded, 0)
};
};
this.resumeNotStopped();
}
resumeNotStopped(): void {
this.torrentRepository
.find({
where: { stopped: false },
})
.then((torrentEntities) => {
for (const torrentEntity of torrentEntities) {
this.addTorrent(torrentEntity.magnet).then();
}
});
}
async addTorrent(magnet: string): Promise<FileEntity[]> {
let torrentEntity = await this.torrentRepository
.findOne({
where: { magnet: magnet },
relations: { files: { torrent: true } },
})
.catch(() => null);
if (torrentEntity && torrentEntity.infoHash) {
//if stopped and complete - return immediately
if (torrentEntity.stopped && torrentEntity.progress === 1) {
return torrentEntity.files;
}
//if already in wt - return files, if not continue as always
const torrent = this.client.get(torrentEntity.infoHash);
if (torrent) {
return torrentEntity.files;
}
}
if (torrentEntity === null) {
torrentEntity = new TorrentEntity();
torrentEntity.magnet = magnet;
await this.torrentRepository.save(torrentEntity);
torrentEntity = await this.torrentRepository
.findOneByOrFail({ magnet: magnet })
.catch(() => null);
}
const torrent = await this.addTorrentReturnOnReady(torrentEntity);
this.mapTorrentToEntity(torrentEntity, torrent);
await this.torrentRepository.save(torrentEntity);
if (torrent.done && torrentEntity.files) {
//in case we added already downloaded one
this.registerDone(torrent);
return torrentEntity.files;
}
const partialFileEntities = this.buildPartialVideoFileEntities(
torrentEntity,
torrent,
);
const fileEntities = await this.videoFilesFacade.addVideoFiles(
partialFileEntities,
);
this.registerProgressSave(torrent);
this.registerDone(torrent);
return fileEntities;
}
private async addTorrentReturnOnReady(
torrentEntity: TorrentEntity,
): Promise<Torrent> {
let torrent = null;
if (torrentEntity.infoHash) {
torrent = this.client.get(torrentEntity.infoHash) as Torrent | undefined;
}
if (!torrent) {
return new Promise((resolve, reject) => {
const torrent = this.client.add(
torrentEntity.magnet,
this.torrentOptions,
);
torrent.on('ready', () => {
resolve(torrent);
});
torrent.on('error', () => {
reject('torrent error');
});
});
}
return Promise.resolve(torrent);
}
private registerProgressSave(torrent: Torrent) {
const notOftenThan = 1000 * 5;
let lastUpdateTime = 0;
torrent.on('download', async () => {
if (lastUpdateTime < Date.now() - notOftenThan) {
await this.torrentRepository.update(
{ infoHash: torrent.infoHash },
{ progress: torrent.progress },
);
for (const file of torrent.files) {
await this.filesRepository.update(
{ relativePath: file.path },
{ progress: file.progress },
);
}
lastUpdateTime = Date.now();
}
});
}
private registerDone(torrent: Torrent) {
if (torrent.done) {
this.actWhenDone(torrent);
return;
}
torrent.on('done', () => {
this.actWhenDone(torrent);
});
}
private actWhenDone(torrent: Torrent) {
this.torrentRepository
.update({ infoHash: torrent.infoHash }, { progress: 1, stopped: true })
.then(async () => {
const torrentEntity = await this.torrentRepository.findOne({
where: { infoHash: torrent.infoHash },
relations: { files: { torrent: true } },
});
//update files to progress 1
await this.filesRepository.update(
{ torrentId: torrentEntity.id },
{ progress: 1 },
);
return torrentEntity;
})
.then(async (torrentEntity) => {
//move file to seeding
if (torrentEntity.magnet.toLowerCase().includes('linkomanija')) {
const torrentInfo = await this.seedClient
.addTorrent(torrentEntity)
.catch(() => null);
if (torrentInfo) {
torrentEntity.transmissionId = parseInt(torrentInfo.id);
await this.torrentRepository.save(torrentEntity);
}
}
return torrentEntity;
})
.then(async (torrentEntity) => {
//update files info with expanders and move to fs provider
const filePaths: string[] = [];
for (const file of torrentEntity.files) {
filePaths.push(file.path);
}
this.videoFilesFacade.updateEntitiesByPath(filePaths).then();
torrent.destroy();
return torrentEntity;
});
}
private mapTorrentToEntity(torrentEntity: TorrentEntity, torrent: Torrent) {
torrentEntity.path = torrent.path;
torrentEntity.infoHash = torrent.infoHash;
torrentEntity.name = torrent.name;
torrentEntity.stopped = torrent.progress === 1;
torrentEntity.progress = torrent.progress;
torrentEntity.size = torrent.length;
torrentEntity.linkomanija = torrentEntity.magnet
.toLowerCase()
.includes('linkomanija');
//map all files (include not video files, will be needed for deletion)
torrentEntity.info = JSON.stringify({
files: torrent.files.map((file) => {
return { path: join(torrent.path, file.path) };
}),
});
}
private buildPartialVideoFileEntities(
torrentEntity: TorrentEntity,
torrent: Torrent,
): Partial<FileEntity>[] {
return torrent.files.reduce(
(filtered: Partial<FileEntity>[], file: TorrentFile) => {
if (
this.configService
.getVideoFilesExt()
.indexOf(extname(file.name).toLowerCase()) > -1
) {
const partialFileEntity: Partial<FileEntity> = {
path: join(torrent.path, file.path),
relativePath: file.path,
torrent: torrentEntity,
size: file.length,
streamProvider: StreamProviderEnum.wt,
fileName: file.name,
linkomanija: torrentEntity.magnet
.toLowerCase()
.includes('linkomanija'),
};
filtered.push(partialFileEntity);
}
return filtered;
},
[],
);
}
getTorrentByInfoHash(torrentInfoHash: string): Torrent | void {
return this.client.get(torrentInfoHash);
}
async stopTorrent(torrentId: number): Promise<void> {
const torrentEntity = await this.torrentRepository.findOne({
where: { id: torrentId },
});
if (!torrentEntity || !torrentEntity.infoHash) {
return;
}
const torrent = await this.getTorrentByInfoHash(torrentEntity.infoHash);
if (!torrent || torrent.done) {
return;
}
return new Promise((resolve, reject) => {
torrent.destroy({ destroyStore: false }, (e) => {
if (e) {
return reject(e);
}
torrentEntity.stopped = true;
this.torrentRepository.save(torrentEntity);
return resolve();
});
});
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TorrentDownloadClient } from './torrent-download.client';
import { VideoFilesModule } from '../../VideoFiles/video-files.module';
import { WebtorrentClient } from './WebtorrentClient/webtorrent.client';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TorrentEntity } from '../../Shared/Entity/torrent.entity';
import { FileEntity } from '../../Shared/Entity/file.entity';
import { TorrentSeedClientModule } from '../SeedClient/torrent-seed-client.module';
@Module({
imports: [
VideoFilesModule,
TypeOrmModule.forFeature([TorrentEntity, FileEntity]),
TorrentSeedClientModule,
],
providers: [WebtorrentClient, TorrentDownloadClient],
exports: [TorrentDownloadClient],
})
export class TorrentDownloadClientModule {}
@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { WebtorrentClient } from './WebtorrentClient/webtorrent.client';
import { FileEntity } from '../../Shared/Entity/file.entity';
import { TorrentFile } from 'webtorrent';
import { InjectRepository } from '@nestjs/typeorm';
import { TorrentEntity } from '../../Shared/Entity/torrent.entity';
import { Repository } from 'typeorm';
@Injectable()
export class TorrentDownloadClient {
constructor(
private readonly webtorrentClient: WebtorrentClient,
@InjectRepository(TorrentEntity)
private readonly torrentRepository: Repository<TorrentEntity>,
) {}
addTorrent(magnet: string): Promise<FileEntity[]> {
return this.webtorrentClient.addTorrent(magnet);
}
async getTorrentFile(fileEntity: FileEntity): Promise<TorrentFile | null> {
const torrentInfoHash = fileEntity.torrent.infoHash;
let torrent = this.webtorrentClient.getTorrentByInfoHash(torrentInfoHash);
if (!torrent) {
await this.webtorrentClient.addTorrent(fileEntity.torrent.magnet);
}
torrent = this.webtorrentClient.getTorrentByInfoHash(torrentInfoHash);
if (!torrent) {
return null;
}
for (const file of torrent.files) {
if (file.path === fileEntity.relativePath) {
return file;
}
}
}
getTorrents(): Promise<TorrentEntity[]> {
return this.torrentRepository.find({
relations: { files: { torrent: true } },
});
}
getTorrentById(id: number): Promise<TorrentEntity> {
return this.torrentRepository.findOne({
where: { id: id },
relations: { files: { torrent: true } },
});
}
stopTorrent(torrentId: number): Promise<void> {
return this.webtorrentClient.stopTorrent(torrentId);
}
async resumeTorrent(torrentId: number): Promise<FileEntity[]> {
const torrentEntity = await this.torrentRepository.findOne({
where: { id: torrentId },
});
if (!torrentEntity || !torrentEntity.magnet) {
return Promise.reject('torrent not found');
}
return this.addTorrent(torrentEntity.magnet);
}
}
@@ -0,0 +1,9 @@
import { NavigationItem } from '../../Shared/Dto/navigation-item.dto';
import { TorrentSearchResult } from './torrent-search-result.class';
export class TorrentSearchClientResponse {
title = '';
navigationItems: NavigationItem[] = [];
searchResults: TorrentSearchResult[] = [];
//todo pagination
}
@@ -0,0 +1,10 @@
export class TorrentSearchResult {
id: string;
magnet: string;
name: string;
size: string;
seeders: string;
leechers: string;
date: string;
info: string; //anything that does not fit to above
}
@@ -0,0 +1,24 @@
import { SearchClientInterface } from '../Interface/search-client.interface';
import { LinkomanijaClient } from '../LinkomanijaClient/linkomanija.client';
import { Injectable } from '@nestjs/common';
import { TorrentSearchClientResponse } from '../../Dto/torrent-search-client-response.dto';
/**
* Bridge is needed as it seems you cannot inject providers from other modules using factory in module level
*/
@Injectable()
export class LinkomanijaClientBridge implements SearchClientInterface {
clientName: string;
constructor(private readonly client: LinkomanijaClient) {
this.clientName = client.clientName;
}
search(query: string) {
return this.client.search(query);
}
customMenu(path: string): Promise<TorrentSearchClientResponse> {
return this.client.customMenu(path);
}
}
@@ -0,0 +1,10 @@
import { TorrentSearchResult } from '../../Dto/torrent-search-result.class';
import { TorrentSearchClientResponse } from '../../Dto/torrent-search-client-response.dto';
export interface SearchClientInterface {
clientName: string;
search(query: string): Promise<TorrentSearchResult[]>;
customMenu(path: string): Promise<TorrentSearchClientResponse>;
}
@@ -0,0 +1,155 @@
import { Agent, RequestOptions, request, get } from 'https';
import {
configService,
ConfigService,
} from '../../../../config/config.service';
import { IncomingMessage } from 'http';
import { Injectable } from '@nestjs/common';
@Injectable()
export class LinkomanijaHttpClient {
private readonly configService: ConfigService;
private readonly requestOptions: RequestOptions;
private consecutiveLoginFails: number;
private lastLoginFailTimeStamp = 0;
constructor() {
this.requestOptions = {
headers: {},
agent: new Agent({ keepAlive: true }),
rejectUnauthorized: false,
hostname: 'www.linkomanija.net',
port: 443,
};
this.configService = configService;
}
public async search(query: string): Promise<string> {
const c = ['c29', 'c52', 'c30', 'c60', 'c53', 'c61', 'c28', 'c62'];
const path = `/browse.php?${c.join('=1&')}=1&incldead=0&search=${query}`;
return this.getPage(path);
}
public async getTop(category: string, time: string): Promise<string> {
const path = `/browse.php?top=${category}&time=${time}`;
return this.getPage(path);
}
private async getPage(path: string): Promise<string> {
const options = {
...this.requestOptions,
path: encodeURI(path),
method: 'POST',
};
const response: IncomingMessage | false = await this.httpGet(options).catch(
() => false,
);
if (response === false) {
return Promise.reject();
}
this.parseAndSetCookie(response);
if (
response.statusCode === 302 &&
response.headers.location.includes('login.php')
) {
//need to log in
const loggedIn = await this.login().catch(() => false);
if (loggedIn || this.checkLoginSafeToAttempt()) {
return this.getPage(path);
}
return Promise.reject();
}
return new Promise((resolve) => {
let rawData = '';
response.on('data', (chunk) => (rawData += chunk));
response.on('end', () => {
resolve(rawData);
});
});
}
private httpGet(options: RequestOptions): Promise<IncomingMessage> {
return new Promise((resolve, reject) => {
const req = get(options, (res) => {
resolve(res);
});
req.on('error', (err) => {
reject(err);
});
});
}
private login(): Promise<boolean> {
if (!this.checkLoginSafeToAttempt()) {
console.log('LM too many login attempts reached');
return Promise.reject();
}
const options = {
...this.requestOptions,
path: '/takelogin.php',
method: 'POST',
};
const userName = process.env['LINKOMANIJA_USERNAME'];
const password = process.env['LINKOMANIJA_PASSWORD'];
const postData = `username=${userName}&password=${password}&commit=Prisijungti`;
options.headers = JSON.parse(JSON.stringify(options.headers));
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
options.headers['Content-Length'] = Buffer.byteLength(postData);
return new Promise((resolve, reject) => {
const req = request(options, (res) => {
this.parseAndSetCookie(res);
if (
res.statusCode === 302 &&
!res.headers.location.includes('login.php')
) {
this.consecutiveLoginFails = 0;
resolve(true);
return;
}
this.increaseLoginFailures();
reject();
});
req.on('error', (e) => {
this.increaseLoginFailures();
reject();
});
req.write(postData);
req.end();
});
}
private parseAndSetCookie(response: IncomingMessage): void {
const cookieToSet = response.headers['set-cookie'];
if (cookieToSet) {
this.requestOptions.headers['cookie'] = cookieToSet[0].split(';')[0];
}
}
private increaseLoginFailures(): void {
this.consecutiveLoginFails++;
this.lastLoginFailTimeStamp = Date.now();
}
private checkLoginSafeToAttempt(): boolean {
return (
this.consecutiveLoginFails <= 5 ||
this.lastLoginFailTimeStamp < Date.now() - 1000 * 60 * 5
);
}
}
@@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import * as cheerio from 'cheerio';
import * as url from 'url';
import { TorrentSearchResult } from '../../../Dto/torrent-search-result.class';
@Injectable()
export class LinkomanijaResponseParser {
parseRaw(rawData: string): TorrentSearchResult[] {
const $ = cheerio.load(rawData);
const torrents = [];
$('body').find('br').replaceWith('\n');
$('table').each(function (iTable, elTable) {
$(elTable)
.find('a.index')
.each(function (iA, elA) {
const torrent = new TorrentSearchResult();
const arrTorDetails = $(elA.parent.parent).find('td');
let sNameAndDesc = $(arrTorDetails[1]).text();
sNameAndDesc = sNameAndDesc.split('\t').join('');
const arrNameAndDesc = sNameAndDesc
.split('\n')
.filter(function (val) {
return val;
});
torrent.name = arrNameAndDesc[0] ? arrNameAndDesc[0].trim() : '';
torrent.info = arrNameAndDesc[1] ? arrNameAndDesc[1].trim() : '';
torrent.date = $(arrTorDetails[4]).text().split('\n')[0];
torrent.size = $(arrTorDetails[5]).text().split('\n').join(' ');
torrent.seeders = $(arrTorDetails[7]).text().split('\n').join(' ');
torrent.leechers = $(arrTorDetails[8]).text().split('\n').join(' ');
if ('attribs' in elA) {
const { id, name } = url.parse(elA.attribs['href'], true).query;
torrent.id = id as string;
const relDlUrl = encodeURI(
'/download.php?id=' +
id +
'&passkey=' +
process.env['LINKOMANIJA_PASSKEY'] +
'&name=' +
name,
);
torrent.magnet = 'https://www.linkomanija.net' + relDlUrl;
}
const arrImgs = $(arrTorDetails).find('img');
if (
arrImgs &&
arrImgs[0] &&
'attribs' in arrImgs[0] &&
arrImgs[0].attribs &&
arrImgs[0].attribs.alt
) {
torrent.info =
arrImgs[0].attribs.alt +
(torrent.info ? ': ' + torrent.info : '');
}
torrents.push(torrent);
});
});
return torrents;
}
}
@@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import { LinkomanijaHttpClient } from './HttpClient/linkomanija-http.client';
import { LinkomanijaResponseParser } from './ResponseParser/linkomanija-response.parser';
import { TorrentSearchResult } from '../../Dto/torrent-search-result.class';
import { SearchClientInterface } from '../Interface/search-client.interface';
import { NavigationItem } from '../../../Shared/Dto/navigation-item.dto';
import { TorrentSearchClientResponse } from '../../Dto/torrent-search-client-response.dto';
@Injectable()
export class LinkomanijaClient implements SearchClientInterface {
clientName = 'LM';
constructor(
private readonly lmHttpClient: LinkomanijaHttpClient,
private readonly lmResponseParser: LinkomanijaResponseParser,
) {}
async search(query: string): Promise<TorrentSearchResult[]> {
const rawData = await this.lmHttpClient.search(query).catch((e) => e);
return this.lmResponseParser.parseRaw(rawData);
}
async getTop(category: string, time: string): Promise<TorrentSearchResult[]> {
const rawData = await this.lmHttpClient
.getTop(category, time)
.catch((e) => e);
return this.lmResponseParser.parseRaw(rawData);
}
async customMenu(path: string): Promise<TorrentSearchClientResponse> {
const [service, category, time] = path.split('/');
const customMenuResponse = new TorrentSearchClientResponse();
if (!service) {
customMenuResponse.navigationItems.push(['LM Top', 'top']);
return Promise.resolve(customMenuResponse);
}
const categories: NavigationItem[] = [
['Movies LT', 'top/53'],
['Movies LT HD', 'top/61'],
['Movies', 'top/29'],
['Movies HD', 'top/52'],
['TV LT', 'top/28'],
['TV LT HD', 'top/62'],
['TV', 'top/30'],
['TV HD', 'top/60'],
];
if (!category) {
customMenuResponse.navigationItems = categories;
customMenuResponse.title = 'LM Top';
return Promise.resolve(customMenuResponse);
}
const times: NavigationItem[] = [
['Savaitės', 'top/' + category + '/7'],
['2 Savaičių', 'top/' + category + '/14'],
['Mėnesio', 'top/' + category + '/30'],
['3 Mėnesių', 'top/' + category + '/90'],
['Metų', 'top/' + category + '/365'],
['Visų laikų', 'top/' + category + '/0'],
];
if (!time) {
customMenuResponse.navigationItems = times;
customMenuResponse.title =
'LM Top, ' + categories.find((value) => value[1] == path)[0];
return Promise.resolve(customMenuResponse);
}
customMenuResponse.title =
'LM Top, ' +
categories.find((value) => value[1] == 'top/' + category)[0] +
', ' +
times.find((value) => value[1] == path)[0];
customMenuResponse.searchResults = await this.getTop(category, time);
return customMenuResponse;
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { LinkomanijaClient } from './linkomanija.client';
import { LinkomanijaHttpClient } from './HttpClient/linkomanija-http.client';
import { LinkomanijaResponseParser } from './ResponseParser/linkomanija-response.parser';
@Module({
imports: [],
providers: [
LinkomanijaClient,
LinkomanijaHttpClient,
LinkomanijaResponseParser,
],
exports: [LinkomanijaClient],
})
export class LinkomanijaModule {}
@@ -0,0 +1,53 @@
import { Inject, Injectable } from '@nestjs/common';
import { SearchClientInterface } from './Interface/search-client.interface';
import { TorrentSearchClientResponse } from '../Dto/torrent-search-client-response.dto';
export const SearchClients = 'SearchClients';
@Injectable()
export class SearchClientFacade {
constructor(
@Inject(SearchClients)
private readonly searchClients: SearchClientInterface[],
) {}
getSearchClientNames(): string[] {
const clients = [];
for (const searchClient of this.searchClients) {
clients.push(searchClient.clientName);
}
return clients;
}
//todo - page
async search(
query: string,
clientName: string,
): Promise<TorrentSearchClientResponse> {
for (const searchClient of this.searchClients) {
if (searchClient.clientName.toLowerCase() === clientName.toLowerCase()) {
const response = new TorrentSearchClientResponse();
response.title = searchClient.clientName + ' search for: ' + query;
response.searchResults = await searchClient.search(query);
return response;
}
}
return Promise.reject('client not found');
}
getCustomMenu(
clientName: string,
path = '',
): Promise<TorrentSearchClientResponse> {
for (const searchClient of this.searchClients) {
if (searchClient.clientName.toLowerCase() === clientName.toLowerCase()) {
return searchClient.customMenu(path);
}
}
return Promise.reject('client not found');
}
}
@@ -0,0 +1,19 @@
import { LinkomanijaModule } from './LinkomanijaClient/linkomanija.module';
import { Module } from '@nestjs/common';
import { SearchClientFacade, SearchClients } from './search-client.facade';
import { LinkomanijaClientBridge } from './ClientBridge/linkomanija-client.bridge';
@Module({
imports: [LinkomanijaModule],
providers: [
SearchClientFacade,
LinkomanijaClientBridge,
{
provide: SearchClients,
useFactory: (...clients) => clients,
inject: [LinkomanijaClientBridge],
},
],
exports: [SearchClientFacade],
})
export class SearchClientModule {}
@@ -0,0 +1,4 @@
export class TorrentInfo {
id: string;
linkomanija: boolean;
}
@@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import * as Transmission from 'transmission';
import { VideoFilesConfig } from '../../../config/video-files.config';
import { configService } from '../../../config/config.service';
import { TorrentEntity } from '../../../Shared/Entity/torrent.entity';
import { TorrentInfo } from '../Dto/torrent-info.dto';
import { FileEntity } from '../../../Shared/Entity/file.entity';
@Injectable()
export class TransmissionClient {
private transmission: Transmission;
private config: VideoFilesConfig = configService.getVideoFilesConfig();
constructor() {
this.transmission = new Transmission(this.config.getTransmissionOptions());
}
addTorrent(torrentEntity: TorrentEntity): Promise<TorrentInfo> {
return new Promise((resolve, reject) => {
this.transmission.addUrl(
torrentEntity.magnet,
{ 'download-dir': torrentEntity.path },
(err, arg) => {
if (err) {
return reject(err);
}
const torrentInfo = new TorrentInfo();
torrentInfo.id = arg.id;
torrentInfo.linkomanija = torrentEntity.magnet
.toLowerCase()
.includes('linkomanija');
return resolve(torrentInfo);
},
);
});
}
findTorrentInfoByFile(fileEntity: FileEntity): Promise<TorrentInfo> {
return new Promise((resolve, reject) => {
this.transmission.get((err, result) => {
if (err) {
return reject(err);
}
for (const torr of result.torrents) {
for (const file of torr.files) {
if (fileEntity.path === torr.downloadDir + '/' + file.name) {
const torrentInfo = new TorrentInfo();
torrentInfo.id = torr.id;
torrentInfo.linkomanija = torr.magnetLink
.toLowerCase()
.includes('linkomanija');
return resolve(torrentInfo);
}
}
}
return reject('file not found');
});
});
}
removeTorrent(torrentEntity: TorrentEntity, removeData = false) {
if (torrentEntity.transmissionId) {
return new Promise((resolve, reject) => {
this.transmission.remove(
[torrentEntity.transmissionId],
removeData,
(e, r) => {
if (e) {
return reject(e);
}
return resolve(r);
},
);
});
}
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TorrentSeedClient } from './torrent-seed.client';
import { TransmissionClient } from './Transmission/transmission.client';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TorrentEntity } from '../../Shared/Entity/torrent.entity';
import { FileEntity } from '../../Shared/Entity/file.entity';
@Module({
imports: [TypeOrmModule.forFeature([TorrentEntity, FileEntity])],
providers: [TorrentSeedClient, TransmissionClient],
exports: [TorrentSeedClient],
})
export class TorrentSeedClientModule {}
@@ -0,0 +1,88 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { TorrentEntity } from '../../Shared/Entity/torrent.entity';
import { Repository } from 'typeorm';
import { TransmissionClient } from './Transmission/transmission.client';
import { TorrentInfo } from './Dto/torrent-info.dto';
import { FileEntity } from '../../Shared/Entity/file.entity';
@Injectable()
export class TorrentSeedClient {
constructor(
private readonly transmissionClient: TransmissionClient,
@InjectRepository(TorrentEntity)
private readonly torrentRepository: Repository<TorrentEntity>,
@InjectRepository(FileEntity)
private readonly filesRepository: Repository<FileEntity>,
) {}
addTorrent(torrentEntity: TorrentEntity): Promise<TorrentInfo> {
return this.transmissionClient.addTorrent(torrentEntity);
}
async addTorrentById(torrId: number): Promise<TorrentInfo> {
const torrentEntity = await this.torrentRepository.findOne({
where: { id: torrId },
});
if (!torrentEntity) {
return Promise.reject('torrent not found');
}
const torrInfo = await this.addTorrent(torrentEntity).catch(() => null);
if (torrInfo) {
await this.torrentRepository.update(
{ id: torrentEntity.id },
{ transmissionId: torrInfo.id, linkomanija: torrInfo.linkomanija },
);
await this.filesRepository.update(
{ torrentId: torrentEntity.id },
{ transmissionId: torrInfo.id, linkomanija: torrInfo.linkomanija },
);
}
return torrInfo;
}
findTorrentInfoByFile(fileEntity: FileEntity): Promise<TorrentInfo | null> {
return this.transmissionClient.findTorrentInfoByFile(fileEntity);
}
async removeTorrent(torrId: number, removeData = false) {
const torrentEntity = await this.torrentRepository.findOne({
where: { id: torrId },
});
if (!torrentEntity) {
return Promise.reject('torrent not found');
}
const result = await this.transmissionClient
.removeTorrent(torrentEntity, removeData)
.catch(() => null);
if (result && !removeData) {
await this.torrentRepository.update(
{ id: torrentEntity.id },
{ transmissionId: null },
);
await this.filesRepository.update(
{ torrentId: torrentEntity.id },
{ transmissionId: null },
);
}
if (result && removeData) {
await this.torrentRepository.delete({
id: torrentEntity.id,
});
await this.filesRepository.update(
{ torrentId: torrentEntity.id },
{ transmissionId: null, torrentId: null, deleted: true },
);
}
return result;
}
}
+1 -5
View File
@@ -1,13 +1,9 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { VideoFilesFacade } from '../VideoFiles/video-files.facade';
@Controller('try') @Controller('try')
export class TryController { export class TryController {
constructor(readonly videoFilesFacade: VideoFilesFacade) {}
@Get('') @Get('')
main() { main() {
this.videoFilesFacade.updateFsVideoFiles(); return { hello: 'world' };
return { doing: 'something' };
} }
} }
+1 -2
View File
@@ -1,12 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TryController } from './try.cotroller'; import { TryController } from './try.cotroller';
import { VideoFilesModule } from '../VideoFiles/video-files.module';
/** /**
* Playground module for playing around * Playground module for playing around
*/ */
@Module({ @Module({
imports: [VideoFilesModule], imports: [],
controllers: [TryController], controllers: [TryController],
providers: [], providers: [],
}) })
@@ -1,5 +1,5 @@
import { FileEntityExpanderInterface } from './file-entity-expander.interface'; import { FileEntityExpanderInterface } from './file-entity-expander.interface';
import { FileEntity } from '../../Entity/file.entity'; import { FileEntity } from '../../../Shared/Entity/file.entity';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
@@ -1,12 +1,16 @@
import { FileEntityExpanderInterface } from './file-entity-expander.interface'; import { FileEntityExpanderInterface } from './file-entity-expander.interface';
import { FileEntity } from '../../Entity/file.entity'; import { FileEntity } from '../../../Shared/Entity/file.entity';
import * as ffmpeg from 'fluent-ffmpeg'; import * as ffmpeg from 'fluent-ffmpeg';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { StreamProviderEnum } from '../../../Shared/Enum/stream-provider.enum';
@Injectable() @Injectable()
export class FileEntityDurationExpander implements FileEntityExpanderInterface { export class FileEntityDurationExpander implements FileEntityExpanderInterface {
expand(fileEntity: FileEntity): Promise<FileEntity> { expand(fileEntity: FileEntity): Promise<FileEntity> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (fileEntity.streamProvider !== StreamProviderEnum.fs) {
return resolve(fileEntity);
}
ffmpeg.ffprobe(fileEntity.path, (err, data) => { ffmpeg.ffprobe(fileEntity.path, (err, data) => {
if (!err) { if (!err) {
//no error handling! //no error handling!
@@ -1,4 +1,4 @@
import { FileEntity } from '../../Entity/file.entity'; import { FileEntity } from '../../../Shared/Entity/file.entity';
export interface FileEntityExpanderInterface { export interface FileEntityExpanderInterface {
expand(fileEntity: FileEntity): Promise<FileEntity>; expand(fileEntity: FileEntity): Promise<FileEntity>;
@@ -1,12 +1,16 @@
import { FileEntityExpanderInterface } from './file-entity-expander.interface'; import { FileEntityExpanderInterface } from './file-entity-expander.interface';
import { FileEntity } from '../../Entity/file.entity'; import { FileEntity } from '../../../Shared/Entity/file.entity';
import { Stats } from 'fs'; import { Stats } from 'fs';
import { stat } from 'fs/promises'; import { stat } from 'fs/promises';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { StreamProviderEnum } from '../../../Shared/Enum/stream-provider.enum';
@Injectable() @Injectable()
export class FileEntitySizeExpander implements FileEntityExpanderInterface { export class FileEntitySizeExpander implements FileEntityExpanderInterface {
async expand(fileEntity: FileEntity): Promise<FileEntity> { async expand(fileEntity: FileEntity): Promise<FileEntity> {
if (fileEntity.streamProvider !== StreamProviderEnum.fs) {
return Promise.resolve(fileEntity);
}
const stats: Stats | false = await stat(fileEntity.path).catch(() => false); const stats: Stats | false = await stat(fileEntity.path).catch(() => false);
if (stats !== false) { if (stats !== false) {
@@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import * as tnp from 'torrent-name-parser'; import * as tnp from 'torrent-name-parser';
import { FileEntityExpanderInterface } from './file-entity-expander.interface'; import { FileEntityExpanderInterface } from './file-entity-expander.interface';
import { FileEntity } from 'src/VideoFiles/Entity/file.entity'; import { FileEntity } from 'src/Shared/Entity/file.entity';
import { VideoFilesUpdateRepository } from '../video-files-update.repository'; import { VideoFilesUpdateRepository } from '../video-files-update.repository';
import { TitleTypeEnum } from '../../Enum/title-type.enum'; import { TitleTypeEnum } from '../../../Shared/Enum/title-type.enum';
//todo - refactor this copy-paste, add types, ect. //todo - refactor this copy-paste, add types, ect.
@Injectable() @Injectable()
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { FileEntityExpanderInterface } from './file-entity-expander.interface';
import { FileEntity } from '../../../Shared/Entity/file.entity';
import { TorrentSeedClient } from '../../../Torrent/SeedClient/torrent-seed.client';
@Injectable()
export class FileEntityTransmissionExpander
implements FileEntityExpanderInterface
{
constructor(private readonly torrentSeedClient: TorrentSeedClient) {}
async expand(fileEntity: FileEntity): Promise<FileEntity> {
const torrentInfo = await this.torrentSeedClient
.findTorrentInfoByFile(fileEntity)
.catch(() => null);
if (torrentInfo === null) {
return Promise.resolve(fileEntity);
}
fileEntity.transmissionId = torrentInfo.id;
fileEntity.linkomanija = torrentInfo.linkomanija;
return fileEntity;
}
}
@@ -1,6 +1,6 @@
import { FileEntityExpanderInterface } from './file-entity-expander.interface'; import { FileEntityExpanderInterface } from './file-entity-expander.interface';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { FileEntity } from '../../Entity/file.entity'; import { FileEntity } from '../../../Shared/Entity/file.entity';
export const FileEntityExpanders = 'FileEntityExpanders'; export const FileEntityExpanders = 'FileEntityExpanders';
@@ -8,7 +8,7 @@ export const FileEntityExpanders = 'FileEntityExpanders';
export class FileEntityExpander implements FileEntityExpanderInterface { export class FileEntityExpander implements FileEntityExpanderInterface {
constructor( constructor(
@Inject(FileEntityExpanders) @Inject(FileEntityExpanders)
private expanders: Array<FileEntityExpanderInterface>, private expanders: FileEntityExpanderInterface[],
) {} ) {}
async expand(fileEntity: FileEntity): Promise<FileEntity> { async expand(fileEntity: FileEntity): Promise<FileEntity> {
@@ -18,11 +18,8 @@ export class FileEntityExpander implements FileEntityExpanderInterface {
const expanderPromise = expander.expand(fileEntity); const expanderPromise = expander.expand(fileEntity);
expanderPromises.push(expanderPromise); expanderPromises.push(expanderPromise);
} }
await Promise.all(expanderPromises);
return new Promise((resolve) => { return fileEntity;
Promise.all(expanderPromises).then(() => {
resolve(fileEntity);
});
});
} }
} }
@@ -36,7 +36,7 @@ export class VideoFilesScannerService {
} }
const findOptions = [path, '-name'].concat( const findOptions = [path, '-name'].concat(
this.config.getVideoFilesExt().join(' -o -name ').split(' '), ('*' + this.config.getVideoFilesExt().join(' -o -name *')).split(' '),
); );
const objCommand = spawn('find', findOptions); const objCommand = spawn('find', findOptions);
@@ -1,7 +1,7 @@
import { Module, Provider } from '@nestjs/common'; import { Module, Provider } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { TitleEntity } from '../Entity/title.entity'; import { TitleEntity } from '../../Shared/Entity/title.entity';
import { FileEntity } from '../Entity/file.entity'; import { FileEntity } from '../../Shared/Entity/file.entity';
import { import {
FileEntityExpander, FileEntityExpander,
FileEntityExpanders, FileEntityExpanders,
@@ -13,6 +13,8 @@ import { VideoFilesUpdateRepository } from './video-files-update.repository';
import { FileEntitySizeExpander } from './Expander/file-entity-size.expander'; import { FileEntitySizeExpander } from './Expander/file-entity-size.expander';
import { FileEntityDefaultsExpander } from './Expander/file-entity-defaults.expander'; import { FileEntityDefaultsExpander } from './Expander/file-entity-defaults.expander';
import { FileEntityDurationExpander } from './Expander/file-entity-duration.expander'; import { FileEntityDurationExpander } from './Expander/file-entity-duration.expander';
import { FileEntityTransmissionExpander } from './Expander/file-entity-transmission.expander';
import { TorrentSeedClientModule } from '../../Torrent/SeedClient/torrent-seed-client.module';
const fileEntityExpanders: Provider<any>[] = [ const fileEntityExpanders: Provider<any>[] = [
FileEntityExpander, FileEntityExpander,
@@ -20,22 +22,26 @@ const fileEntityExpanders: Provider<any>[] = [
FileEntitySizeExpander, FileEntitySizeExpander,
FileEntityTitleInfoExpander, FileEntityTitleInfoExpander,
FileEntityDurationExpander, FileEntityDurationExpander,
FileEntityTransmissionExpander,
{ {
provide: FileEntityExpanders, provide: FileEntityExpanders,
useFactory: (...expanders) => expanders, useFactory: (...expanders) => expanders,
inject: [ inject: [
//expanders run in parallel, make sure they don't rely on each other's result //expanders run in parallel, make sure they don't rely on each other's result
//todo - linkomanija, webtorrent, transmission expanders
FileEntityDefaultsExpander, FileEntityDefaultsExpander,
FileEntitySizeExpander, FileEntitySizeExpander,
FileEntityTitleInfoExpander, FileEntityTitleInfoExpander,
FileEntityDurationExpander, FileEntityDurationExpander,
FileEntityTransmissionExpander,
], ],
}, },
]; ];
@Module({ @Module({
imports: [TypeOrmModule.forFeature([TitleEntity, FileEntity])], imports: [
TypeOrmModule.forFeature([TitleEntity, FileEntity]),
TorrentSeedClientModule,
],
controllers: [], controllers: [],
providers: [ providers: [
VideoFilesUpdateService, VideoFilesUpdateService,
@@ -1,10 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { FileEntity } from '../Entity/file.entity'; import { FileEntity } from '../../Shared/Entity/file.entity';
import { In, Not, Repository, UpdateResult } from 'typeorm'; import { In, Not, Repository, UpdateResult } from 'typeorm';
import { StreamProviderEnum } from '../../Streamer/ReadStreamProvider/stream-provider.enum'; import { StreamProviderEnum } from '../../Shared/Enum/stream-provider.enum';
import { TitleEntity } from '../Entity/title.entity'; import { TitleEntity } from '../../Shared/Entity/title.entity';
import { TitleTypeEnum } from '../Enum/title-type.enum'; import { TitleTypeEnum } from '../../Shared/Enum/title-type.enum';
@Injectable() @Injectable()
export class VideoFilesUpdateRepository { export class VideoFilesUpdateRepository {
@@ -78,6 +78,7 @@ export class VideoFilesUpdateRepository {
): Promise<FileEntity> { ): Promise<FileEntity> {
let fileEntity = await this.fileRepository.findOne({ let fileEntity = await this.fileRepository.findOne({
where: { path: path }, where: { path: path },
relations: { torrent: true },
}); });
if (fileEntity === null) { if (fileEntity === null) {
@@ -87,6 +88,7 @@ export class VideoFilesUpdateRepository {
await this.fileRepository.save(fileEntity); await this.fileRepository.save(fileEntity);
fileEntity = await this.fileRepository.findOne({ fileEntity = await this.fileRepository.findOne({
where: { path: path }, where: { path: path },
relations: { torrent: true },
}); });
} }
} }
@@ -97,4 +99,11 @@ export class VideoFilesUpdateRepository {
public saveFiles(fileEntities: FileEntity[]) { public saveFiles(fileEntities: FileEntity[]) {
return this.fileRepository.save(fileEntities); return this.fileRepository.save(fileEntities);
} }
public findFilesByPaths(paths: string[]): Promise<FileEntity[]> {
return this.fileRepository.find({
where: { path: In(paths) },
relations: { torrent: true },
});
}
} }
@@ -2,11 +2,16 @@ import { Injectable } from '@nestjs/common';
import { VideoFilesScannerService } from './Scanner/video-files-scanner.service'; import { VideoFilesScannerService } from './Scanner/video-files-scanner.service';
import { VideoFilesUpdateRepository } from './video-files-update.repository'; import { VideoFilesUpdateRepository } from './video-files-update.repository';
import { FileEntityExpander } from './Expander/file-entity.expander'; import { FileEntityExpander } from './Expander/file-entity.expander';
import { FileEntity } from '../Entity/file.entity'; import { FileEntity } from '../../Shared/Entity/file.entity';
import { StreamProviderEnum } from '../../Streamer/ReadStreamProvider/stream-provider.enum'; import { StreamProviderEnum } from '../../Shared/Enum/stream-provider.enum';
import { configService } from '../../config/config.service';
import { VideoFilesConfig } from '../../config/video-files.config';
@Injectable() @Injectable()
export class VideoFilesUpdateService { export class VideoFilesUpdateService {
private readonly config: VideoFilesConfig =
configService.getVideoFilesConfig();
constructor( constructor(
private readonly scanner: VideoFilesScannerService, private readonly scanner: VideoFilesScannerService,
private readonly repository: VideoFilesUpdateRepository, private readonly repository: VideoFilesUpdateRepository,
@@ -20,20 +25,30 @@ export class VideoFilesUpdateService {
const filesInDb = await this.repository.getMatchingPaths(filesInFs); const filesInDb = await this.repository.getMatchingPaths(filesInFs);
const filesToAddToDb = filesInFs.filter( const filesToAddToDb = filesInFs.filter(
(filePath) => !filesInDb.includes(filePath), (filePath) =>
!filesInDb.includes(filePath) && !this.isPathToIgnore(filePath),
); );
const entitiesToSave = []; const entitiesToSave = [];
for (const fileToAddToDb of filesToAddToDb) { for (const fileToAddToDb of filesToAddToDb) {
if (this.checkPathToIgnore(fileToAddToDb)) { const fileEntity = await this.repository.findOrCreateFile(
fileToAddToDb,
false,
);
//check if download is complete
if (
fileEntity.streamProvider === StreamProviderEnum.wt &&
fileEntity.torrent &&
fileEntity.torrent.stopped === false
) {
continue; continue;
} }
fileEntity.relativePath = filesToRelativePaths[fileToAddToDb];
fileEntity.streamProvider = StreamProviderEnum.fs;
const fileEntity = await this.buildFileEntity( await this.expander.expand(fileEntity);
fileToAddToDb,
filesToRelativePaths[fileToAddToDb],
);
entitiesToSave.push(fileEntity); entitiesToSave.push(fileEntity);
} }
@@ -41,19 +56,63 @@ export class VideoFilesUpdateService {
await this.repository.saveFiles(entitiesToSave); await this.repository.saveFiles(entitiesToSave);
} }
private checkPathToIgnore(filePath: string): boolean { private isPathToIgnore(filePath: string): boolean {
//todo - move to separate provider, include in settings for (const pathPattern of this.config.getPathPatternsToIgnore()) {
return filePath.indexOf('AOE1') > -1; if (filePath.indexOf(pathPattern) > -1) {
return true;
}
}
return false;
} }
private async buildFileEntity( async updateEntitiesByPath(filePaths: string[]) {
path: string, const fileEntitiesToUpdate = [];
relativePath: string, const fileEntities = await this.repository.findFilesByPaths(filePaths);
): Promise<FileEntity> {
const fileEntity = await this.repository.findOrCreateFile(path, false);
fileEntity.relativePath = relativePath;
fileEntity.streamProvider = StreamProviderEnum.fs;
return await this.expander.expand(fileEntity); for (const fileEntity of fileEntities) {
if (!fileEntity.relativePath) {
continue;
}
if (
fileEntity.streamProvider === StreamProviderEnum.wt &&
fileEntity.torrent &&
fileEntity.torrent.stopped === false
) {
continue;
}
fileEntity.streamProvider = StreamProviderEnum.fs;
await this.expander.expand(fileEntity);
fileEntitiesToUpdate.push(fileEntity);
}
await this.repository.saveFiles(fileEntitiesToUpdate);
}
public async buildAndSaveEntitiesFromPartial(
partialFileEntities: Partial<FileEntity>[],
): Promise<FileEntity[]> {
const entitiesToSave: FileEntity[] = [];
for (const partialFileEntity of partialFileEntities) {
let fileEntity = await this.repository.findOrCreateFile(
partialFileEntity.path,
false,
);
fileEntity = { ...fileEntity, ...partialFileEntity };
await this.expander.expand(fileEntity);
entitiesToSave.push(fileEntity);
}
await this.repository.saveFiles(entitiesToSave);
return this.repository.findFilesByPaths(
entitiesToSave.map((fileEntity) => {
return fileEntity.path;
}),
);
} }
} }
+54 -6
View File
@@ -1,12 +1,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { TitleEntity } from './Entity/title.entity'; import { TitleEntity } from '../Shared/Entity/title.entity';
import { TitleTypeEnum } from './Enum/title-type.enum'; import { TitleTypeEnum } from '../Shared/Enum/title-type.enum';
import { FileEntity } from './Entity/file.entity'; import { FileEntity } from '../Shared/Entity/file.entity';
import { VideoFilesUpdateService } from './Update/video-files-update.service'; import { VideoFilesUpdateService } from './Update/video-files-update.service';
import NotificationResponse from '../KodiApi/Dto/notification-response.dto';
import { Stats } from 'fs';
import { stat, unlink } from 'fs/promises';
//todo - refactor provider as facade
@Injectable() @Injectable()
export class VideoFilesFacade { export class VideoFilesFacade {
constructor( constructor(
@@ -29,7 +31,7 @@ export class VideoFilesFacade {
async getTitleWithFiles(titleId: number): Promise<TitleEntity | null> { async getTitleWithFiles(titleId: number): Promise<TitleEntity | null> {
return this.titleRepository return this.titleRepository
.findOne({ .findOne({
relations: { files: true }, relations: { files: { torrent: true } },
where: { id: titleId, files: { deleted: false } }, where: { id: titleId, files: { deleted: false } },
}) })
.catch(() => null); .catch(() => null);
@@ -37,11 +39,57 @@ export class VideoFilesFacade {
async getFile(fileId: number): Promise<FileEntity | null> { async getFile(fileId: number): Promise<FileEntity | null> {
return this.fileRepository return this.fileRepository
.findOne({ where: { id: fileId } }) .findOne({ where: { id: fileId }, relations: { torrent: true } })
.catch(() => null); .catch(() => null);
} }
/**
* Scan for new files and remove deleted.
* Once all hooks are working correctly, this one shouldn't be needed anymore
*/
updateFsVideoFiles(): void { updateFsVideoFiles(): void {
this.videoFilesUpdateService.updateFsVideoFiles().then(); this.videoFilesUpdateService.updateFsVideoFiles().then();
} }
addVideoFiles(
partialFileEntities: Partial<FileEntity>[],
): Promise<FileEntity[]> {
return this.videoFilesUpdateService.buildAndSaveEntitiesFromPartial(
partialFileEntities,
);
}
/**
* if entity with provided path exists and fully downloaded, it will
* - update entity properties, like size, duration, etc.
* - move to fs provider
* @param filePaths
*/
updateEntitiesByPath(filePaths: string[]) {
return this.videoFilesUpdateService.updateEntitiesByPath(filePaths);
}
async deleteFile(fileId: number): Promise<boolean> {
const fileEntity = await this.fileRepository.findOne({
where: { id: fileId },
});
if (!fileEntity) {
return false;
}
fileEntity.deleted = true;
await this.fileRepository.save(fileEntity);
const stats: Stats | false = await stat(fileEntity.path).catch(() => false);
if (stats === false) {
//file already deleted
return true;
}
return unlink(fileEntity.path)
.then(() => true)
.catch(() => false);
}
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { TitleEntity } from './Entity/title.entity'; import { TitleEntity } from '../Shared/Entity/title.entity';
import { FileEntity } from './Entity/file.entity'; import { FileEntity } from '../Shared/Entity/file.entity';
import { VideoFilesFacade } from './video-files.facade'; import { VideoFilesFacade } from './video-files.facade';
import { VideoFilesUpdateModule } from './Update/video-files-update.module'; import { VideoFilesUpdateModule } from './Update/video-files-update.module';
+6
View File
@@ -3,6 +3,7 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { TypeOrmConfig } from './type-orm.config'; import { TypeOrmConfig } from './type-orm.config';
import { PathsConfig } from './paths.config'; import { PathsConfig } from './paths.config';
import { VideoFilesConfig } from './video-files.config'; import { VideoFilesConfig } from './video-files.config';
import { RecentSearchesConfig } from './recent-searches.config';
env.config(); env.config();
@@ -10,6 +11,7 @@ export class ConfigService {
private typeOrmConfig: TypeOrmConfig = new TypeOrmConfig(this); private typeOrmConfig: TypeOrmConfig = new TypeOrmConfig(this);
private pathsConfig: PathsConfig = new PathsConfig(this); private pathsConfig: PathsConfig = new PathsConfig(this);
private videoFilesConfig: VideoFilesConfig = new VideoFilesConfig(this); private videoFilesConfig: VideoFilesConfig = new VideoFilesConfig(this);
private recentSearches: RecentSearchesConfig = new RecentSearchesConfig(this);
public getEnv(key: string): any { public getEnv(key: string): any {
return process.env[key]; return process.env[key];
@@ -32,6 +34,10 @@ export class ConfigService {
public getVideoFilesConfig(): VideoFilesConfig { public getVideoFilesConfig(): VideoFilesConfig {
return this.videoFilesConfig; return this.videoFilesConfig;
} }
public getRecentSearchesConfig(): RecentSearchesConfig {
return this.recentSearches;
}
} }
export const configService = new ConfigService(); export const configService = new ConfigService();
-4
View File
@@ -14,10 +14,6 @@ export class PathsConfig {
return this.getPathByEnvKey('STATIC_SERVE_FOLDER'); return this.getPathByEnvKey('STATIC_SERVE_FOLDER');
} }
public getRecentSearchesFolder(): string {
return this.getPathByEnvKey('RECENT_SEARCHES_FOLDER');
}
public getPathByEnvKey(key: string) { public getPathByEnvKey(key: string) {
const relPath = this.configService.getEnv(key); const relPath = this.configService.getEnv(key);
+17
View File
@@ -0,0 +1,17 @@
import { ConfigService } from './config.service';
export class RecentSearchesConfig {
constructor(private readonly configService: ConfigService) {
this.configService = configService;
}
public getRecentSearchesFolder(): string {
return this.configService
.getPaths()
.getPathByEnvKey('RECENT_SEARCHES_FOLDER');
}
public getRecentSearchesLimit(): number {
return parseInt(this.configService.getEnv('RECENT_SEARCHES_LIMIT'));
}
}
+4 -3
View File
@@ -1,14 +1,15 @@
import { LrtCategory } from '../KodiApi/LRT/LrtApiClient/Entity/lrt-category.entity'; import { LrtCategory } from '../KodiApi/LRT/LrtApiClient/Entity/lrt-category.entity';
import { ConfigService } from './config.service'; import { ConfigService } from './config.service';
import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type'; import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type';
import { TitleEntity } from '../VideoFiles/Entity/title.entity'; import { TitleEntity } from '../Shared/Entity/title.entity';
import { FileEntity } from '../VideoFiles/Entity/file.entity'; import { FileEntity } from '../Shared/Entity/file.entity';
import { TorrentEntity } from '../Shared/Entity/torrent.entity';
export class TypeOrmConfig { export class TypeOrmConfig {
constructor(configService: ConfigService) { constructor(configService: ConfigService) {
this.type = 'sqlite'; this.type = 'sqlite';
this.database = configService.getEnv('DB_PATH'); this.database = configService.getEnv('DB_PATH');
this.entities = [LrtCategory, TitleEntity, FileEntity]; this.entities = [LrtCategory, TitleEntity, FileEntity, TorrentEntity];
} }
type: 'sqlite'; type: 'sqlite';
database: string; database: string;
+21 -1
View File
@@ -1,5 +1,4 @@
import { ConfigService } from './config.service'; import { ConfigService } from './config.service';
import { join } from 'path';
export class VideoFilesConfig { export class VideoFilesConfig {
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
@@ -13,4 +12,25 @@ export class VideoFilesConfig {
public getVideoFilesExt(): string[] { public getVideoFilesExt(): string[] {
return JSON.parse(this.configService.getEnv('VIDEO_FILES_EXT')); return JSON.parse(this.configService.getEnv('VIDEO_FILES_EXT'));
} }
public getPathPatternsToIgnore(): string[] {
return JSON.parse(this.configService.getEnv('PATTERNS_TO_IGNORE'));
}
public getTorrentDownloadDir(): string {
return this.configService
.getPaths()
.getPathByEnvKey('TORRENT_DOWNLOAD_DIR');
}
public getTransmissionOptions() {
return {
host: this.configService.getEnv('TRANSMISSION_CLIENT_HOST'), // # default 'localhost'
//port: 9091, // # default 9091
//username: "username", // # default blank
//password: "password", // # default blank
//ssl: true, //# default false use https
//url: "/my/other/url" // # default '/transmission/rpc'
};
}
} }
+1
View File
@@ -6,6 +6,7 @@ import { HttpExceptionFilter } from './Exception/http-exception.filter';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalFilters(new HttpExceptionFilter());
app.getHttpAdapter().getInstance().set('etag', false);
await app.listen(configService.getPort()); await app.listen(configService.getPort());
} }