mirror of
https://github.com/sakaljurgis/kodi-api-server.git
synced 2026-07-08 20:37:41 +00:00
migrations; torr search, dl, seed, delete; files scan, delete; configs
This commit is contained in:
+8
-1
@@ -3,8 +3,15 @@ STATIC_SERVE_FOLDER="data/static"
|
||||
LOG_FILE_REQUESTS="data/static/static_log.txt"
|
||||
DB_PATH="./data/db/db.db"
|
||||
RECENT_SEARCHES_FOLDER="./data/recent_searches"
|
||||
RECENT_SEARCHES_LIMIT=20
|
||||
VIDEO_FILES_FOLDERS='[
|
||||
"/home/user/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
|
||||
|
||||
@@ -5,36 +5,44 @@ export class CreateFilesTable1672729641013 implements MigrationInterface {
|
||||
await queryRunner.query(
|
||||
`create table files
|
||||
(
|
||||
id INTEGER
|
||||
id INTEGER
|
||||
primary key autoincrement,
|
||||
path VARCHAR(512),
|
||||
deleted TINYINT,
|
||||
size INTEGER,
|
||||
file_name VARCHAR(512),
|
||||
infos TEXT,
|
||||
info TEXT,
|
||||
title_id INTEGER,
|
||||
season INTEGER default NULL,
|
||||
duration REAL,
|
||||
transmission INTEGER default NULL,
|
||||
lm INTEGER default NULL
|
||||
path VARCHAR(512),
|
||||
relative_path TEXT,
|
||||
deleted TINYINT,
|
||||
size INTEGER,
|
||||
file_name VARCHAR(512),
|
||||
infos TEXT,
|
||||
info TEXT,
|
||||
title_id INTEGER,
|
||||
season INTEGER default NULL,
|
||||
duration REAL,
|
||||
transmission_id INTEGER default NULL,
|
||||
linkomanija INTEGER default NULL,
|
||||
progress REAL,
|
||||
stream_provider TEXT default 'fs' not null
|
||||
);`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`create unique index path
|
||||
`create unique index files_path
|
||||
on files (path);`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`create index season
|
||||
`create index files_season
|
||||
on files (season);`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`create index title_id
|
||||
`create index 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> {
|
||||
|
||||
@@ -7,8 +7,11 @@ export class CreateLrtCategoriesTable1672730402752
|
||||
await queryRunner.query(
|
||||
`create table lrt_categories
|
||||
(
|
||||
category_url TEXT,
|
||||
category_id TEXT
|
||||
category_url VARCHAR(256),
|
||||
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
|
||||
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> {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
Generated
+3210
-35
File diff suppressed because it is too large
Load Diff
+8
-3
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"private": false,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"prebuild": "rimraf dist",
|
||||
@@ -33,7 +33,7 @@
|
||||
"@nestjs/platform-express": "^9.0.0",
|
||||
"@nestjs/serve-static": "^3.0.0",
|
||||
"@nestjs/typeorm": "^9.0.1",
|
||||
"@types/fluent-ffmpeg": "^2.1.20",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"dotenv": "^16.0.3",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
@@ -42,16 +42,21 @@
|
||||
"sqlite": "^4.1.2",
|
||||
"sqlite3": "^5.1.2",
|
||||
"torrent-name-parser": "^0.6.5",
|
||||
"typeorm": "^0.3.11"
|
||||
"transmission": "^0.4.10",
|
||||
"typeorm": "^0.3.11",
|
||||
"webtorrent": "^1.9.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.0.0",
|
||||
"@nestjs/schematics": "^9.0.0",
|
||||
"@nestjs/testing": "^9.0.0",
|
||||
"@types/cheerio": "^0.22.31",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/fluent-ffmpeg": "^2.1.20",
|
||||
"@types/jest": "28.1.8",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@types/webtorrent": "^0.109.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"copyfiles": "^2.4.1",
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Controller, Get, Head, Param, Req, Res } from '@nestjs/common';
|
||||
import ApiResponse from '../Dto/api-response.dto';
|
||||
import { AllFilesService } from './all-files.service';
|
||||
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')
|
||||
export class AllFilesController {
|
||||
@@ -23,6 +25,16 @@ export class AllFilesController {
|
||||
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')
|
||||
@Head('play/:fileId')
|
||||
play(
|
||||
@@ -37,7 +49,7 @@ export class AllFilesController {
|
||||
getTitle(
|
||||
@Param('titleId') titleId: 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(
|
||||
parseInt(titleId),
|
||||
parseInt(seasonId),
|
||||
|
||||
@@ -9,5 +9,6 @@ import { VideoFilesModule } from '../../VideoFiles/video-files.module';
|
||||
imports: [StreamerModule, VideoFilesModule],
|
||||
controllers: [AllFilesController],
|
||||
providers: [AllFilesService, KodiApiResponseFactory],
|
||||
exports: [AllFilesService],
|
||||
})
|
||||
export class AllFilesModule {}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { KodiApiResponseFactory } from '../kodi-api-response.factory';
|
||||
import { StreamerFacade } from '../../Streamer/streamer.facade';
|
||||
import { Request, Response } from 'express';
|
||||
import { VideoFilesFacade } from '../../VideoFiles/video-files.facade';
|
||||
import { TitleTypeEnum } from '../../VideoFiles/Enum/title-type.enum';
|
||||
import { FileEntity } from '../../VideoFiles/Entity/file.entity';
|
||||
import { TitleEntity } from '../../VideoFiles/Entity/title.entity';
|
||||
import { TitleTypeEnum } from '../../Shared/Enum/title-type.enum';
|
||||
import { FileEntity } from '../../Shared/Entity/file.entity';
|
||||
import { TitleEntity } from '../../Shared/Entity/title.entity';
|
||||
import { KodiApiResponse } from '../Dto/kodi-api-response.type';
|
||||
import NotificationResponse from '../Dto/notification-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AllFilesService {
|
||||
@@ -17,19 +19,17 @@ export class AllFilesService {
|
||||
) {}
|
||||
|
||||
getMenu(): ApiResponse {
|
||||
const apiResponse = this.koiApiResponseFactory.createApiResponse();
|
||||
apiResponse
|
||||
.createItem()
|
||||
.setLabel('Movies')
|
||||
.setPath('all/movie')
|
||||
.setToFolder();
|
||||
apiResponse
|
||||
.createItem()
|
||||
.setLabel('Shows')
|
||||
.setPath('all/show')
|
||||
.setToFolder();
|
||||
|
||||
return apiResponse;
|
||||
return this.koiApiResponseFactory
|
||||
.createApiResponse()
|
||||
.addNavigationItems(
|
||||
[
|
||||
['Movies', 'movie'],
|
||||
['Shows', 'show'],
|
||||
['Scan for new Files', 'scan'],
|
||||
],
|
||||
'all/',
|
||||
)
|
||||
.setNoSort();
|
||||
}
|
||||
|
||||
async getListOfTitles(type: TitleTypeEnum): Promise<ApiResponse> {
|
||||
@@ -50,16 +50,16 @@ export class AllFilesService {
|
||||
return response;
|
||||
}
|
||||
|
||||
async loadTitle(titleId: number, seasonId: number): Promise<ApiResponse> {
|
||||
async loadTitle(titleId: number, seasonId: number): Promise<KodiApiResponse> {
|
||||
const titleEntity = titleId
|
||||
? await this.videoFilesFacade.getTitleWithFiles(titleId)
|
||||
: null;
|
||||
|
||||
if (titleEntity === null) {
|
||||
//todo - alert instead of full api response
|
||||
return this.koiApiResponseFactory
|
||||
.createApiResponse()
|
||||
.setTitle('Not found' + (titleId ? ' ' + titleId : ''));
|
||||
return this.koiApiResponseFactory.createNotificationResponse(
|
||||
'Not found' + (titleId ? ' ' + titleId : ''),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
if (titleEntity.type === TitleTypeEnum.movie) {
|
||||
@@ -78,21 +78,33 @@ export class AllFilesService {
|
||||
return this.buildSeasonsResponse(titleEntity);
|
||||
}
|
||||
|
||||
private buildFilesResponse(
|
||||
buildFilesResponse(
|
||||
title: string,
|
||||
fileEntities: Array<FileEntity>,
|
||||
torrentContext = false,
|
||||
): ApiResponse {
|
||||
const apiResponse = this.koiApiResponseFactory.createApiResponse();
|
||||
apiResponse.setTitle(title);
|
||||
for (const fileEntity of fileEntities) {
|
||||
apiResponse
|
||||
const item = apiResponse
|
||||
.createItem()
|
||||
.setLabel(fileEntity.fileName)
|
||||
.setPath('/api?path=all/play/' + fileEntity.id)
|
||||
.setToPlayable()
|
||||
.setPlot('Size ' + fileEntity.size)
|
||||
.setPlot(this.buildPlot(fileEntity))
|
||||
.setSize(fileEntity.size)
|
||||
.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;
|
||||
@@ -102,7 +114,6 @@ export class AllFilesService {
|
||||
const apiResponse = this.koiApiResponseFactory.createApiResponse();
|
||||
apiResponse.setTitle(titleEntity.title);
|
||||
|
||||
//todo - add num files count
|
||||
const seasons = [];
|
||||
const filesCount = {};
|
||||
for (const fileEntity of titleEntity.files) {
|
||||
@@ -144,4 +155,60 @@ export class AllFilesService {
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import ResponseItem from './response-item.dto';
|
||||
import { NavigationItem } from '../../Shared/Dto/navigation-item.dto';
|
||||
|
||||
export default class ApiResponse {
|
||||
items: Array<ResponseItem> = [];
|
||||
content = 'videos';
|
||||
updateList = true;
|
||||
selectList: boolean | undefined;
|
||||
category: string | undefined;
|
||||
play: string | undefined;
|
||||
msgBoxOK: string | undefined;
|
||||
@@ -40,4 +42,25 @@ export default class ApiResponse {
|
||||
|
||||
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;
|
||||
@@ -1,6 +1,7 @@
|
||||
export default class NotificationResponse {
|
||||
notification: string;
|
||||
refreshContainer: boolean;
|
||||
readonly notification: string;
|
||||
readonly refreshContainer: boolean;
|
||||
readonly updateList = false;
|
||||
|
||||
constructor(message: string, refreshContainer = true) {
|
||||
this.notification = message;
|
||||
|
||||
@@ -4,10 +4,17 @@ import { firstValueFrom } from 'rxjs';
|
||||
import { SearchResponseInterface } from '../Interface/search-response.interface';
|
||||
import { SearchResponseDto } from '../Dto/search-response.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()
|
||||
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> {
|
||||
const url = 'https://www.lrt.lt/api/search?type=3&category_id=' + catId;
|
||||
@@ -36,6 +43,23 @@ export class LrtApiCategoryClient {
|
||||
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;
|
||||
}
|
||||
|
||||
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> {
|
||||
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?get_terms=1
|
||||
const resp = await firstValueFrom(this.httpService.get(url));
|
||||
@@ -43,6 +44,18 @@ export class LrtApiSearchClient {
|
||||
searchItem.img_path_prefix +
|
||||
'282x158' +
|
||||
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);
|
||||
|
||||
loadedCategories.add(searchItem.category_title);
|
||||
@@ -60,10 +73,17 @@ export class LrtApiSearchClient {
|
||||
return this.findCategoryIdInLrt(categoryUrl);
|
||||
}
|
||||
|
||||
private saveCategory(categoryUrl: string, catId: string) {
|
||||
private saveCategory(
|
||||
categoryUrl: string,
|
||||
catId: string,
|
||||
catTitle: string,
|
||||
thumb: string,
|
||||
) {
|
||||
const cat = new LrtCategory();
|
||||
cat.categoryUrl = categoryUrl;
|
||||
cat.categoryId = catId;
|
||||
cat.title = catTitle;
|
||||
cat.thumb = thumb;
|
||||
this.lrtCategoriesRepository.save(cat).then();
|
||||
}
|
||||
|
||||
@@ -91,9 +111,7 @@ export class LrtApiSearchClient {
|
||||
const interim = body.indexOf('"', start);
|
||||
const end = body.indexOf('"', interim + 1);
|
||||
if (end > interim) {
|
||||
const catId = body.substring(interim + 1, end);
|
||||
this.saveCategory(categoryUrl, catId);
|
||||
return catId;
|
||||
return body.substring(interim + 1, end);
|
||||
} else {
|
||||
throw 'unexpected response body 1';
|
||||
}
|
||||
|
||||
@@ -7,4 +7,13 @@ export class LrtCategory {
|
||||
|
||||
@Column({ name: 'category_id' })
|
||||
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 { LrtApiCategoryClient } from './Client/lrt-api-category.client';
|
||||
import { LrtApiPlaylistClient } from './Client/lrt-api-playlist.client';
|
||||
import { LrtCategory } from './Entity/lrt-category.entity';
|
||||
|
||||
@Injectable()
|
||||
export class LrtApiClient {
|
||||
@@ -23,4 +24,8 @@ export class LrtApiClient {
|
||||
getPlaylist(mediaUrl: string): Promise<string> {
|
||||
return this.playlistClient.getPlaylistUrl(mediaUrl);
|
||||
}
|
||||
|
||||
getRecentCategories(count: number): Promise<LrtCategory[]> {
|
||||
return this.categoryClient.getRecentCategories(count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export class LrtController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
getLrtMenu(): ApiResponse {
|
||||
getLrtMenu(): Promise<ApiResponse> {
|
||||
return this.lrtService.getMainMenu();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import { LrtController } from './lrt.controller';
|
||||
import { LrtService } from './lrt.service';
|
||||
import { KodiApiResponseFactory } from '../kodi-api-response.factory';
|
||||
import { LrtApiClientModule } from './LrtApiClient/lrt-api-client.module';
|
||||
import { RecentSearchesService } from '../RecentSearches/recent-searches.service';
|
||||
import { RecentSearchesModule } from '../RecentSearches/recent-searches.module';
|
||||
|
||||
@Module({
|
||||
imports: [LrtApiClientModule],
|
||||
imports: [LrtApiClientModule, RecentSearchesModule],
|
||||
controllers: [LrtController],
|
||||
providers: [KodiApiResponseFactory, LrtService, RecentSearchesService],
|
||||
providers: [KodiApiResponseFactory, LrtService],
|
||||
})
|
||||
export class LrtModule {}
|
||||
|
||||
@@ -12,34 +12,34 @@ export class LrtService {
|
||||
private readonly recentSearchesService: RecentSearchesService,
|
||||
) {}
|
||||
|
||||
getMainMenu(): ApiResponse {
|
||||
getMainMenu(): Promise<ApiResponse> {
|
||||
const apiResponse = this.kodiApiResponseFactory.createApiResponse();
|
||||
apiResponse.setNoSort().setTitle('LRT.lt');
|
||||
this.createMainMenu(apiResponse);
|
||||
|
||||
return apiResponse;
|
||||
return this.createMainMenu(apiResponse);
|
||||
}
|
||||
|
||||
private createMainMenu(apiResponse: ApiResponse): void {
|
||||
//todo - refactor to 5 most recent searches + viskas?
|
||||
const items = {
|
||||
'lrt/recent': 'neseniai ieskoti',
|
||||
'lrt/tema/vaikams': 'vaikams',
|
||||
'lrt/tema/sportas': 'sportas',
|
||||
'lrt/tema/kultura': 'kultura',
|
||||
'lrt/tema/muzika': 'muzika',
|
||||
'lrt/tema/viskas': 'viskas',
|
||||
'lrt/tema/tv-laidos': 'tv-laidos',
|
||||
};
|
||||
apiResponse
|
||||
.createItem()
|
||||
.setPath('lrt/search')
|
||||
.setActionSearch()
|
||||
.setLabel('paieška');
|
||||
private async createMainMenu(apiResponse: ApiResponse): Promise<ApiResponse> {
|
||||
apiResponse.addNavigationItems(
|
||||
[
|
||||
['paieška', 'search', ''],
|
||||
['neseniai ieskoti', 'recent'],
|
||||
['viskas', 'tema/viskas'],
|
||||
],
|
||||
'lrt/',
|
||||
);
|
||||
|
||||
for (const [path, label] of Object.entries(items)) {
|
||||
apiResponse.createItem().setLabel(label).setToFolder().setPath(path);
|
||||
const recentCategories = await this.lrtApiClient.getRecentCategories(7);
|
||||
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> {
|
||||
|
||||
@@ -4,16 +4,21 @@ import { configService, ConfigService } from '../../config/config.service';
|
||||
import { KodiApiResponseFactory } from '../kodi-api-response.factory';
|
||||
import { join } from 'path';
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import { RecentSearchesConfig } from '../../config/recent-searches.config';
|
||||
|
||||
@Injectable()
|
||||
export class RecentSearchesService {
|
||||
private readonly configService: ConfigService;
|
||||
private readonly config: RecentSearchesConfig;
|
||||
|
||||
constructor(private readonly kodiApiResponseFactory: KodiApiResponseFactory) {
|
||||
this.configService = configService;
|
||||
this.config = configService.getRecentSearchesConfig();
|
||||
}
|
||||
|
||||
async getRecentSearches(module: string, path: string): Promise<ApiResponse> {
|
||||
const data = await this.getRecentSearchesData(module);
|
||||
const response = this.kodiApiResponseFactory.createApiResponse();
|
||||
const response = this.kodiApiResponseFactory
|
||||
.createApiResponse()
|
||||
.setNoSort();
|
||||
for (const recentSearch of data) {
|
||||
response
|
||||
.createItem()
|
||||
@@ -28,14 +33,19 @@ export class RecentSearchesService {
|
||||
async addRecentSearch(module: string, term: string): Promise<void> {
|
||||
const data = await this.getRecentSearchesData(module);
|
||||
const index = data.indexOf(term);
|
||||
if (index > -1) {
|
||||
if (index === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (index > 0) {
|
||||
data.splice(index, 1);
|
||||
}
|
||||
data.push(term);
|
||||
const rawData = JSON.stringify(data);
|
||||
const filePath = this.getFilePath(module);
|
||||
data.unshift(term);
|
||||
|
||||
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>> {
|
||||
@@ -46,9 +56,6 @@ export class RecentSearchesService {
|
||||
}
|
||||
|
||||
private getFilePath(module: string): string {
|
||||
return join(
|
||||
this.configService.getPaths().getRecentSearchesFolder(),
|
||||
module + '.json',
|
||||
);
|
||||
return join(this.config.getRecentSearchesFolder(), module + '.json');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {
|
||||
async use(req: Request, res: Response, next: NextFunction) {
|
||||
let path = '';
|
||||
if (req.query && req.query.path) {
|
||||
let path = '';
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -8,4 +8,9 @@ export class KodiApiController {
|
||||
getMainMenu() {
|
||||
return this.kodiApiService.getMainMenu();
|
||||
}
|
||||
|
||||
@Get('test')
|
||||
getCurrentTest() {
|
||||
return this.kodiApiService.getCurrentTest();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ import { LrtModule } from './LRT/lrt.module';
|
||||
import { KodiApiService } from './kodi-api.service';
|
||||
import { KodiApiResponseFactory } from './kodi-api-response.factory';
|
||||
import { AllFilesModule } from './AllFiles/all-files.module';
|
||||
import { TorrentModule } from './Torrent/torrent.module';
|
||||
|
||||
@Module({
|
||||
imports: [LrtModule, AllFilesModule],
|
||||
imports: [LrtModule, AllFilesModule, TorrentModule],
|
||||
controllers: [KodiApiController],
|
||||
providers: [KodiApiService, KodiApiResponseFactory],
|
||||
})
|
||||
|
||||
@@ -9,23 +9,27 @@ export class KodiApiService {
|
||||
) {}
|
||||
|
||||
getMainMenu(): ApiResponse {
|
||||
const apiResponse = this.kodiApiResponseFactory.createApiResponse();
|
||||
|
||||
apiResponse.setTitle('Menu');
|
||||
this.createMainMenu(apiResponse);
|
||||
|
||||
return apiResponse;
|
||||
return this.kodiApiResponseFactory
|
||||
.createApiResponse()
|
||||
.setTitle('Menu')
|
||||
.addNavigationItems([
|
||||
['All Files', 'all'],
|
||||
['Torr', 'torr'],
|
||||
['LRT.lt', 'lrt'],
|
||||
//['Test', 'test'],
|
||||
]);
|
||||
}
|
||||
|
||||
private createMainMenu(apiResponse: ApiResponse): void {
|
||||
const items = {
|
||||
all: 'All files',
|
||||
torr: 'Torr',
|
||||
lrt: 'LRT.lt',
|
||||
};
|
||||
|
||||
for (const [path, label] of Object.entries(items)) {
|
||||
apiResponse.createItem().setLabel(label).setToFolder().setPath(path);
|
||||
}
|
||||
getCurrentTest() {
|
||||
return this.kodiApiResponseFactory
|
||||
.createApiResponse()
|
||||
.setTitle('Menu')
|
||||
.addNavigationItems([
|
||||
['select 1', 'all'],
|
||||
['select 2', 'lrt'],
|
||||
['select 3', 'torr'],
|
||||
['select 4 search', 'test', ''],
|
||||
])
|
||||
.setShowAsSelectList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export class RequestLogMiddleware implements NestMiddleware {
|
||||
async use(req: Request, res: Response, next: NextFunction) {
|
||||
const strIP = this.extractIp(req);
|
||||
this.log(strIP + ' ' + req.method + ' ' + decodeURI(req.url));
|
||||
console.log(decodeURI(req.url));
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -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 { 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')
|
||||
export class FileEntity {
|
||||
@@ -44,13 +45,19 @@ export class FileEntity {
|
||||
@Column({ name: 'relative_path' })
|
||||
relativePath: string;
|
||||
|
||||
//todo - consider if these 3 below should be better organized
|
||||
@Column()
|
||||
transmission: boolean;
|
||||
@Column({ name: 'transmission_id' })
|
||||
transmissionId: number;
|
||||
|
||||
@Column()
|
||||
linkomanija: boolean;
|
||||
|
||||
@Column()
|
||||
webtorrent: boolean;
|
||||
@Column({ name: 'torrent_id' })
|
||||
torrentId: number;
|
||||
|
||||
@ManyToOne(() => TorrentEntity, (torrent) => torrent.files)
|
||||
@JoinColumn({ name: 'torrent_id' })
|
||||
torrent: TorrentEntity;
|
||||
|
||||
@Column({ type: 'float' })
|
||||
progress: number;
|
||||
}
|
||||
@@ -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 { MimeService } from '../../Mime/mime.service';
|
||||
import { Stats } from 'fs';
|
||||
@@ -6,13 +6,17 @@ import { stat } from 'fs/promises';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ReadStreamCreatableFile } from './read-stream-creatable-file.dto';
|
||||
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()
|
||||
export class FsReadStreamCreatableProvider
|
||||
implements ReadStreamCreatableProviderInterface
|
||||
{
|
||||
constructor(private readonly mimeService: MimeService) {}
|
||||
constructor(
|
||||
private readonly mimeService: MimeService,
|
||||
private readonly videoFilesService: VideoFilesFacade,
|
||||
) {}
|
||||
|
||||
supports(fileEntity: FileEntity): boolean {
|
||||
return fileEntity.streamProvider === StreamProviderEnum.fs;
|
||||
@@ -24,7 +28,7 @@ export class FsReadStreamCreatableProvider
|
||||
const filePath = fileEntity.path;
|
||||
const stats: Stats | false = await stat(filePath).catch(() => false);
|
||||
if (stats === false) {
|
||||
//todo - set the file to deleted, since no longer available. Events?
|
||||
this.videoFilesService.updateFsVideoFiles();
|
||||
throw new NotFoundException('file not found');
|
||||
}
|
||||
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';
|
||||
|
||||
export interface ReadStreamCreatableProviderInterface {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { ReadStreamCreatableProviderInterface } from './read-stream-creatable-provider.interface';
|
||||
|
||||
@@ -17,5 +17,6 @@ export class ReadStreamCreatableProvider {
|
||||
return provider.getReadStreamCreatable(fileEntity);
|
||||
}
|
||||
}
|
||||
return Promise.reject('no stream provider');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,8 @@ export class ResponseSenderService {
|
||||
}
|
||||
|
||||
readable.pipe(response);
|
||||
readable.on('error', (e) => {
|
||||
//todo - figure out the Error: Writable stream closed prematurely
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { StreamerService } from './StreamerService/streamer.service';
|
||||
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
ReadStreamCreatableProviders,
|
||||
} from './ReadStreamProvider/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({
|
||||
imports: [],
|
||||
imports: [TorrentDownloadClientModule, VideoFilesModule],
|
||||
providers: [
|
||||
MimeService,
|
||||
RequestRangeService,
|
||||
@@ -22,10 +25,11 @@ import { FsReadStreamCreatableProvider } from './ReadStreamProvider/FileSystem/f
|
||||
StreamerFacade,
|
||||
ReadStreamCreatableProvider,
|
||||
FsReadStreamCreatableProvider,
|
||||
WtReadStreamCreatableProvider,
|
||||
{
|
||||
provide: ReadStreamCreatableProviders,
|
||||
useFactory: (...providers) => providers,
|
||||
inject: [FsReadStreamCreatableProvider],
|
||||
inject: [FsReadStreamCreatableProvider, WtReadStreamCreatableProvider],
|
||||
},
|
||||
],
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
+72
@@ -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,13 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { VideoFilesFacade } from '../VideoFiles/video-files.facade';
|
||||
|
||||
@Controller('try')
|
||||
export class TryController {
|
||||
constructor(readonly videoFilesFacade: VideoFilesFacade) {}
|
||||
|
||||
@Get('')
|
||||
main() {
|
||||
this.videoFilesFacade.updateFsVideoFiles();
|
||||
return { doing: 'something' };
|
||||
return { hello: 'world' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TryController } from './try.cotroller';
|
||||
import { VideoFilesModule } from '../VideoFiles/video-files.module';
|
||||
|
||||
/**
|
||||
* Playground module for playing around
|
||||
*/
|
||||
@Module({
|
||||
imports: [VideoFilesModule],
|
||||
imports: [],
|
||||
controllers: [TryController],
|
||||
providers: [],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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 { Injectable } from '@nestjs/common';
|
||||
import { StreamProviderEnum } from '../../../Shared/Enum/stream-provider.enum';
|
||||
|
||||
@Injectable()
|
||||
export class FileEntityDurationExpander implements FileEntityExpanderInterface {
|
||||
expand(fileEntity: FileEntity): Promise<FileEntity> {
|
||||
return new Promise((resolve) => {
|
||||
if (fileEntity.streamProvider !== StreamProviderEnum.fs) {
|
||||
return resolve(fileEntity);
|
||||
}
|
||||
ffmpeg.ffprobe(fileEntity.path, (err, data) => {
|
||||
if (!err) {
|
||||
//no error handling!
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileEntity } from '../../Entity/file.entity';
|
||||
import { FileEntity } from '../../../Shared/Entity/file.entity';
|
||||
|
||||
export interface FileEntityExpanderInterface {
|
||||
expand(fileEntity: FileEntity): Promise<FileEntity>;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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 { stat } from 'fs/promises';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { StreamProviderEnum } from '../../../Shared/Enum/stream-provider.enum';
|
||||
|
||||
@Injectable()
|
||||
export class FileEntitySizeExpander implements FileEntityExpanderInterface {
|
||||
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);
|
||||
|
||||
if (stats !== false) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as tnp from 'torrent-name-parser';
|
||||
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 { TitleTypeEnum } from '../../Enum/title-type.enum';
|
||||
import { TitleTypeEnum } from '../../../Shared/Enum/title-type.enum';
|
||||
|
||||
//todo - refactor this copy-paste, add types, ect.
|
||||
@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 { Inject, Injectable } from '@nestjs/common';
|
||||
import { FileEntity } from '../../Entity/file.entity';
|
||||
import { FileEntity } from '../../../Shared/Entity/file.entity';
|
||||
|
||||
export const FileEntityExpanders = 'FileEntityExpanders';
|
||||
|
||||
@@ -8,7 +8,7 @@ export const FileEntityExpanders = 'FileEntityExpanders';
|
||||
export class FileEntityExpander implements FileEntityExpanderInterface {
|
||||
constructor(
|
||||
@Inject(FileEntityExpanders)
|
||||
private expanders: Array<FileEntityExpanderInterface>,
|
||||
private expanders: FileEntityExpanderInterface[],
|
||||
) {}
|
||||
|
||||
async expand(fileEntity: FileEntity): Promise<FileEntity> {
|
||||
@@ -18,11 +18,8 @@ export class FileEntityExpander implements FileEntityExpanderInterface {
|
||||
const expanderPromise = expander.expand(fileEntity);
|
||||
expanderPromises.push(expanderPromise);
|
||||
}
|
||||
await Promise.all(expanderPromises);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
Promise.all(expanderPromises).then(() => {
|
||||
resolve(fileEntity);
|
||||
});
|
||||
});
|
||||
return fileEntity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export class VideoFilesScannerService {
|
||||
}
|
||||
|
||||
const findOptions = [path, '-name'].concat(
|
||||
this.config.getVideoFilesExt().join(' -o -name ').split(' '),
|
||||
('*' + this.config.getVideoFilesExt().join(' -o -name *')).split(' '),
|
||||
);
|
||||
|
||||
const objCommand = spawn('find', findOptions);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module, Provider } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TitleEntity } from '../Entity/title.entity';
|
||||
import { FileEntity } from '../Entity/file.entity';
|
||||
import { TitleEntity } from '../../Shared/Entity/title.entity';
|
||||
import { FileEntity } from '../../Shared/Entity/file.entity';
|
||||
import {
|
||||
FileEntityExpander,
|
||||
FileEntityExpanders,
|
||||
@@ -13,6 +13,8 @@ import { VideoFilesUpdateRepository } from './video-files-update.repository';
|
||||
import { FileEntitySizeExpander } from './Expander/file-entity-size.expander';
|
||||
import { FileEntityDefaultsExpander } from './Expander/file-entity-defaults.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>[] = [
|
||||
FileEntityExpander,
|
||||
@@ -20,22 +22,26 @@ const fileEntityExpanders: Provider<any>[] = [
|
||||
FileEntitySizeExpander,
|
||||
FileEntityTitleInfoExpander,
|
||||
FileEntityDurationExpander,
|
||||
FileEntityTransmissionExpander,
|
||||
{
|
||||
provide: FileEntityExpanders,
|
||||
useFactory: (...expanders) => expanders,
|
||||
inject: [
|
||||
//expanders run in parallel, make sure they don't rely on each other's result
|
||||
//todo - linkomanija, webtorrent, transmission expanders
|
||||
FileEntityDefaultsExpander,
|
||||
FileEntitySizeExpander,
|
||||
FileEntityTitleInfoExpander,
|
||||
FileEntityDurationExpander,
|
||||
FileEntityTransmissionExpander,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TitleEntity, FileEntity])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([TitleEntity, FileEntity]),
|
||||
TorrentSeedClientModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
VideoFilesUpdateService,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { StreamProviderEnum } from '../../Streamer/ReadStreamProvider/stream-provider.enum';
|
||||
import { TitleEntity } from '../Entity/title.entity';
|
||||
import { TitleTypeEnum } from '../Enum/title-type.enum';
|
||||
import { StreamProviderEnum } from '../../Shared/Enum/stream-provider.enum';
|
||||
import { TitleEntity } from '../../Shared/Entity/title.entity';
|
||||
import { TitleTypeEnum } from '../../Shared/Enum/title-type.enum';
|
||||
|
||||
@Injectable()
|
||||
export class VideoFilesUpdateRepository {
|
||||
@@ -78,6 +78,7 @@ export class VideoFilesUpdateRepository {
|
||||
): Promise<FileEntity> {
|
||||
let fileEntity = await this.fileRepository.findOne({
|
||||
where: { path: path },
|
||||
relations: { torrent: true },
|
||||
});
|
||||
|
||||
if (fileEntity === null) {
|
||||
@@ -87,6 +88,7 @@ export class VideoFilesUpdateRepository {
|
||||
await this.fileRepository.save(fileEntity);
|
||||
fileEntity = await this.fileRepository.findOne({
|
||||
where: { path: path },
|
||||
relations: { torrent: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -97,4 +99,11 @@ export class VideoFilesUpdateRepository {
|
||||
public saveFiles(fileEntities: FileEntity[]) {
|
||||
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 { VideoFilesUpdateRepository } from './video-files-update.repository';
|
||||
import { FileEntityExpander } from './Expander/file-entity.expander';
|
||||
import { FileEntity } from '../Entity/file.entity';
|
||||
import { StreamProviderEnum } from '../../Streamer/ReadStreamProvider/stream-provider.enum';
|
||||
import { FileEntity } from '../../Shared/Entity/file.entity';
|
||||
import { StreamProviderEnum } from '../../Shared/Enum/stream-provider.enum';
|
||||
import { configService } from '../../config/config.service';
|
||||
import { VideoFilesConfig } from '../../config/video-files.config';
|
||||
|
||||
@Injectable()
|
||||
export class VideoFilesUpdateService {
|
||||
private readonly config: VideoFilesConfig =
|
||||
configService.getVideoFilesConfig();
|
||||
|
||||
constructor(
|
||||
private readonly scanner: VideoFilesScannerService,
|
||||
private readonly repository: VideoFilesUpdateRepository,
|
||||
@@ -20,20 +25,30 @@ export class VideoFilesUpdateService {
|
||||
|
||||
const filesInDb = await this.repository.getMatchingPaths(filesInFs);
|
||||
const filesToAddToDb = filesInFs.filter(
|
||||
(filePath) => !filesInDb.includes(filePath),
|
||||
(filePath) =>
|
||||
!filesInDb.includes(filePath) && !this.isPathToIgnore(filePath),
|
||||
);
|
||||
|
||||
const entitiesToSave = [];
|
||||
|
||||
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;
|
||||
}
|
||||
fileEntity.relativePath = filesToRelativePaths[fileToAddToDb];
|
||||
fileEntity.streamProvider = StreamProviderEnum.fs;
|
||||
|
||||
const fileEntity = await this.buildFileEntity(
|
||||
fileToAddToDb,
|
||||
filesToRelativePaths[fileToAddToDb],
|
||||
);
|
||||
await this.expander.expand(fileEntity);
|
||||
|
||||
entitiesToSave.push(fileEntity);
|
||||
}
|
||||
@@ -41,19 +56,63 @@ export class VideoFilesUpdateService {
|
||||
await this.repository.saveFiles(entitiesToSave);
|
||||
}
|
||||
|
||||
private checkPathToIgnore(filePath: string): boolean {
|
||||
//todo - move to separate provider, include in settings
|
||||
return filePath.indexOf('AOE1') > -1;
|
||||
private isPathToIgnore(filePath: string): boolean {
|
||||
for (const pathPattern of this.config.getPathPatternsToIgnore()) {
|
||||
if (filePath.indexOf(pathPattern) > -1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async buildFileEntity(
|
||||
path: string,
|
||||
relativePath: string,
|
||||
): Promise<FileEntity> {
|
||||
const fileEntity = await this.repository.findOrCreateFile(path, false);
|
||||
fileEntity.relativePath = relativePath;
|
||||
fileEntity.streamProvider = StreamProviderEnum.fs;
|
||||
async updateEntitiesByPath(filePaths: string[]) {
|
||||
const fileEntitiesToUpdate = [];
|
||||
const fileEntities = await this.repository.findFilesByPaths(filePaths);
|
||||
|
||||
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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TitleEntity } from './Entity/title.entity';
|
||||
import { TitleTypeEnum } from './Enum/title-type.enum';
|
||||
import { FileEntity } from './Entity/file.entity';
|
||||
import { TitleEntity } from '../Shared/Entity/title.entity';
|
||||
import { TitleTypeEnum } from '../Shared/Enum/title-type.enum';
|
||||
import { FileEntity } from '../Shared/Entity/file.entity';
|
||||
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()
|
||||
export class VideoFilesFacade {
|
||||
constructor(
|
||||
@@ -29,7 +31,7 @@ export class VideoFilesFacade {
|
||||
async getTitleWithFiles(titleId: number): Promise<TitleEntity | null> {
|
||||
return this.titleRepository
|
||||
.findOne({
|
||||
relations: { files: true },
|
||||
relations: { files: { torrent: true } },
|
||||
where: { id: titleId, files: { deleted: false } },
|
||||
})
|
||||
.catch(() => null);
|
||||
@@ -37,11 +39,57 @@ export class VideoFilesFacade {
|
||||
|
||||
async getFile(fileId: number): Promise<FileEntity | null> {
|
||||
return this.fileRepository
|
||||
.findOne({ where: { id: fileId } })
|
||||
.findOne({ where: { id: fileId }, relations: { torrent: true } })
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for new files and remove deleted.
|
||||
* Once all hooks are working correctly, this one shouldn't be needed anymore
|
||||
*/
|
||||
updateFsVideoFiles(): void {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TitleEntity } from './Entity/title.entity';
|
||||
import { FileEntity } from './Entity/file.entity';
|
||||
import { TitleEntity } from '../Shared/Entity/title.entity';
|
||||
import { FileEntity } from '../Shared/Entity/file.entity';
|
||||
import { VideoFilesFacade } from './video-files.facade';
|
||||
import { VideoFilesUpdateModule } from './Update/video-files-update.module';
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { TypeOrmConfig } from './type-orm.config';
|
||||
import { PathsConfig } from './paths.config';
|
||||
import { VideoFilesConfig } from './video-files.config';
|
||||
import { RecentSearchesConfig } from './recent-searches.config';
|
||||
|
||||
env.config();
|
||||
|
||||
@@ -10,6 +11,7 @@ export class ConfigService {
|
||||
private typeOrmConfig: TypeOrmConfig = new TypeOrmConfig(this);
|
||||
private pathsConfig: PathsConfig = new PathsConfig(this);
|
||||
private videoFilesConfig: VideoFilesConfig = new VideoFilesConfig(this);
|
||||
private recentSearches: RecentSearchesConfig = new RecentSearchesConfig(this);
|
||||
|
||||
public getEnv(key: string): any {
|
||||
return process.env[key];
|
||||
@@ -32,6 +34,10 @@ export class ConfigService {
|
||||
public getVideoFilesConfig(): VideoFilesConfig {
|
||||
return this.videoFilesConfig;
|
||||
}
|
||||
|
||||
public getRecentSearchesConfig(): RecentSearchesConfig {
|
||||
return this.recentSearches;
|
||||
}
|
||||
}
|
||||
|
||||
export const configService = new ConfigService();
|
||||
|
||||
@@ -14,10 +14,6 @@ export class PathsConfig {
|
||||
return this.getPathByEnvKey('STATIC_SERVE_FOLDER');
|
||||
}
|
||||
|
||||
public getRecentSearchesFolder(): string {
|
||||
return this.getPathByEnvKey('RECENT_SEARCHES_FOLDER');
|
||||
}
|
||||
|
||||
public getPathByEnvKey(key: string) {
|
||||
const relPath = this.configService.getEnv(key);
|
||||
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { LrtCategory } from '../KodiApi/LRT/LrtApiClient/Entity/lrt-category.entity';
|
||||
import { ConfigService } from './config.service';
|
||||
import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type';
|
||||
import { TitleEntity } from '../VideoFiles/Entity/title.entity';
|
||||
import { FileEntity } from '../VideoFiles/Entity/file.entity';
|
||||
import { TitleEntity } from '../Shared/Entity/title.entity';
|
||||
import { FileEntity } from '../Shared/Entity/file.entity';
|
||||
import { TorrentEntity } from '../Shared/Entity/torrent.entity';
|
||||
|
||||
export class TypeOrmConfig {
|
||||
constructor(configService: ConfigService) {
|
||||
this.type = 'sqlite';
|
||||
this.database = configService.getEnv('DB_PATH');
|
||||
this.entities = [LrtCategory, TitleEntity, FileEntity];
|
||||
this.entities = [LrtCategory, TitleEntity, FileEntity, TorrentEntity];
|
||||
}
|
||||
type: 'sqlite';
|
||||
database: string;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ConfigService } from './config.service';
|
||||
import { join } from 'path';
|
||||
|
||||
export class VideoFilesConfig {
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
@@ -13,4 +12,25 @@ export class VideoFilesConfig {
|
||||
public getVideoFilesExt(): string[] {
|
||||
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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { HttpExceptionFilter } from './Exception/http-exception.filter';
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.getHttpAdapter().getInstance().set('etag', false);
|
||||
await app.listen(configService.getPort());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user