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