From 558a625f117a734522411634270f50a7c2946641 Mon Sep 17 00:00:00 2001 From: LeoMortari Date: Fri, 3 Oct 2025 00:59:51 -0300 Subject: [PATCH] Add - Classe Gemini, endpoint de criacao de video --- .env.exemple | 5 +- package.json | 3 + src/app.module.ts | 5 +- src/gemini/gemini.controller.spec.ts | 18 ++++++ src/gemini/gemini.module.ts | 4 ++ src/gemini/gemini.service.spec.ts | 18 ++++++ src/gemini/gemini.service.ts | 45 ++++++++++++++ src/modules/ollama/ollama.controller.ts | 44 -------------- src/modules/ollama/ollama.module.ts | 2 - src/modules/videos/dto/create-video-dto.ts | 69 +++++++++++++--------- src/modules/videos/videos.controller.ts | 33 +++++------ src/modules/videos/videos.service.ts | 17 ++++++ src/shared/types/pagination.ts | 11 ++++ 13 files changed, 182 insertions(+), 92 deletions(-) create mode 100644 src/gemini/gemini.controller.spec.ts create mode 100644 src/gemini/gemini.module.ts create mode 100644 src/gemini/gemini.service.spec.ts create mode 100644 src/gemini/gemini.service.ts delete mode 100644 src/modules/ollama/ollama.controller.ts create mode 100644 src/shared/types/pagination.ts diff --git a/.env.exemple b/.env.exemple index 104888d..528fe5b 100644 --- a/.env.exemple +++ b/.env.exemple @@ -1 +1,4 @@ -DATABASE_URL="postgresql://username:password@ip_server:port/database?schema=public" \ No newline at end of file +DATABASE_URL="postgresql://username:password@ip_server:port/database?schema=public" +KEYCLOAK_URL="https://auth.clipperia.com.br" +YOUTUBE_API_URL="https://totally-real-dingo.ngrok-free.app" +GEMINI_API_KEY="GEMINI API KEY" \ No newline at end of file diff --git a/package.json b/package.json index 00698d3..f50ada8 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,9 @@ "cross-env": "10.0.0", "dayjs": "1.11.13", "jwks-rsa": "3.2.0", + "lodash": "4.17.21", "ollama": "0.6.0", + "openai": "6.1.0", "passport": "0.7.0", "passport-jwt": "4.0.1", "reflect-metadata": "0.2.2", @@ -48,6 +50,7 @@ "@types/bcrypt": "6.0.0", "@types/express": "5.0.0", "@types/jest": "30.0.0", + "@types/lodash": "4.17.20", "@types/node": "22.10.7", "@types/passport-jwt": "4.0.1", "@types/supertest": "6.0.2", diff --git a/src/app.module.ts b/src/app.module.ts index de814c9..eff1bcc 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,6 +10,8 @@ import { UsuariosModule } from './modules/usuarios/usuarios.module'; import { LoggerMiddleware } from './middleware/logger.middleware'; import { RolesGuard } from './modules/auth/roles.guard'; import { OllamaModule } from './modules/ollama/ollama.module'; +import { GeminiService } from './gemini/gemini.service'; +import { GeminiModule } from './gemini/gemini.module'; @Module({ imports: [ @@ -19,9 +21,10 @@ import { OllamaModule } from './modules/ollama/ollama.module'; AuthModule, UsuariosModule, OllamaModule, + GeminiModule, ], controllers: [AppController], - providers: [AppService, RolesGuard], + providers: [AppService, RolesGuard, GeminiService], }) export class AppModule { configure(consumer: MiddlewareConsumer) { diff --git a/src/gemini/gemini.controller.spec.ts b/src/gemini/gemini.controller.spec.ts new file mode 100644 index 0000000..6823f1a --- /dev/null +++ b/src/gemini/gemini.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GeminiController } from './gemini.controller'; + +describe('GeminiController', () => { + let controller: GeminiController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [GeminiController], + }).compile(); + + controller = module.get(GeminiController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/gemini/gemini.module.ts b/src/gemini/gemini.module.ts new file mode 100644 index 0000000..57f4663 --- /dev/null +++ b/src/gemini/gemini.module.ts @@ -0,0 +1,4 @@ +import { Module } from '@nestjs/common'; + +@Module({}) +export class GeminiModule {} diff --git a/src/gemini/gemini.service.spec.ts b/src/gemini/gemini.service.spec.ts new file mode 100644 index 0000000..92f3163 --- /dev/null +++ b/src/gemini/gemini.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GeminiService } from './gemini.service'; + +describe('GeminiService', () => { + let service: GeminiService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [GeminiService], + }).compile(); + + service = module.get(GeminiService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/gemini/gemini.service.ts b/src/gemini/gemini.service.ts new file mode 100644 index 0000000..f2ad2ff --- /dev/null +++ b/src/gemini/gemini.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; + +import OpenAI from 'openai'; + +@Injectable() +export class GeminiService { + private readonly model = 'gemini-2.0-flash'; + private readonly systemPrompt = + 'Você é um assistente de IA que responde em portugues.'; + + private readonly gemini = new OpenAI({ + apiKey: process.env.GEMINI_API_KEY, + baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/', + }); + + public async generate({ + model = this.model, + message, + systemPrompt = this.systemPrompt, + }: { + model?: string; + message: string; + systemPrompt?: string; + }) { + try { + const response = await this.gemini.chat.completions.create({ + model, + messages: [ + { + role: 'system', + content: systemPrompt, + }, + { + role: 'user', + content: message, + }, + ], + }); + + return response; + } catch (error) { + throw new Error(error as string); + } + } +} diff --git a/src/modules/ollama/ollama.controller.ts b/src/modules/ollama/ollama.controller.ts deleted file mode 100644 index 81c829c..0000000 --- a/src/modules/ollama/ollama.controller.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Body, Controller, Get, Post, Query } from '@nestjs/common'; -import { OllamaService } from './ollama.service'; -import { ChatDto } from './dto/chat.dto'; - -@Controller('ollama') -export class OllamaController { - constructor(private readonly ollamaService: OllamaService) {} - - @Get('models') - public async getModels( - @Query('onlyChats') { onlyChats }: { onlyChats: boolean }, - ) { - return await this.ollamaService.getModels({ onlyChats }); - } - - @Post('chat') - public async chat(@Body() body: ChatDto) { - const { message, model } = body; - - if (!message) { - throw new Error('O atributo message é obrigatório'); - } - - if (!model) { - const [defaultModel] = await this.ollamaService.getModels({ - onlyChats: true, - }); - - if (!defaultModel) { - throw new Error('Nenhum modelo encontrado'); - } - - return await this.ollamaService.generateChat({ - model: defaultModel.name, - message, - }); - } - - return await this.ollamaService.generateChat({ - model, - message, - }); - } -} diff --git a/src/modules/ollama/ollama.module.ts b/src/modules/ollama/ollama.module.ts index d1b9b61..2680197 100644 --- a/src/modules/ollama/ollama.module.ts +++ b/src/modules/ollama/ollama.module.ts @@ -1,9 +1,7 @@ import { Module } from '@nestjs/common'; -import { OllamaController } from './ollama.controller'; import { OllamaService } from './ollama.service'; @Module({ - controllers: [OllamaController], providers: [OllamaService], }) export class OllamaModule {} diff --git a/src/modules/videos/dto/create-video-dto.ts b/src/modules/videos/dto/create-video-dto.ts index befd483..621bd2e 100644 --- a/src/modules/videos/dto/create-video-dto.ts +++ b/src/modules/videos/dto/create-video-dto.ts @@ -1,34 +1,35 @@ +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; + +import { Transform } from 'class-transformer'; import { - IsEnum, - IsInt, - IsISO8601, + IsArray, + IsNumber, IsOptional, IsString, IsUrl, MaxLength, } from 'class-validator'; -import { video_situation } from '@root/generated/prisma'; + +dayjs.extend(duration); export class CreateVideoDto { + @IsOptional() + @IsString({ message: 'Id deve ser uma string' }) + @MaxLength(244) + id?: string; + + @IsOptional() + @IsArray({ message: 'Tags deve ser um array' }) + tags?: string[]; + @IsUrl() @MaxLength(244) url!: string; @IsOptional() - @IsEnum(video_situation) - situation?: video_situation; - - @IsOptional() - @IsString() - @MaxLength(244) - error_message?: string; - - @IsOptional() - @IsInt() - clips_quantity?: number; - - @IsOptional() - times?: unknown; + @IsArray({ message: 'Categories deve ser um array' }) + categories?: string[]; @IsOptional() @IsString() @@ -36,24 +37,38 @@ export class CreateVideoDto { title?: string; @IsOptional() - @IsString() + @IsUrl() @MaxLength(244) - filename?: string; + thumbnail?: string; @IsOptional() - @IsString() + @IsString({ message: 'Videoid deve ser uma string' }) @MaxLength(244) videoid?: string; @IsOptional() - @IsISO8601() - datetime_download?: string; + @IsString({ message: 'Duration deve ser uma string' }) + @Transform(({ value }: { value: number }) => { + const duration = dayjs.duration(value, 'seconds'); + + return duration.format('HH:mm:ss'); + }) + duration?: string; @IsOptional() - @IsISO8601() - datetime_convert?: string; + @IsString() + @MaxLength(244) + description?: string; @IsOptional() - @IsISO8601() - datetime_posted?: string; + @IsNumber( + { allowNaN: false, allowInfinity: false }, + { message: 'Timestamp deve ser um número' }, + ) + @Transform(({ value }: { value: number }) => { + const duration = dayjs(value * 1000); + + return duration.format('DD/MM/YYYY HH:mm:ss'); + }) + timestamp?: string; } diff --git a/src/modules/videos/videos.controller.ts b/src/modules/videos/videos.controller.ts index 0241d7b..35fa046 100644 --- a/src/modules/videos/videos.controller.ts +++ b/src/modules/videos/videos.controller.ts @@ -7,27 +7,20 @@ import { Body, Delete, UseGuards, + Post, } from '@nestjs/common'; import { Prisma, videos, video_situation } from 'generated/prisma'; -import { VideosService } from './videos.service'; -import { VideoResponseDto } from './dto/video-response.dto'; -import { KeycloakAuthGuard } from '../auth/keycloak-auth.guard'; -import { Roles } from '../auth/decorator/roles.decorator'; -import { ListVideosQueryDto } from './dto/list-videos-query.dto'; -import { VideoMetadataDto } from 'src/shared/dto/video-metadata'; +import { KeycloakAuthGuard } from '@modules/auth/keycloak-auth.guard'; +import { Roles } from '@modules/auth/decorator/roles.decorator'; +import { VideoMetadataDto } from '@shared/dto/video-metadata'; -type PaginatedResponse = { - content: T[]; - pagination: { - page: number; - perPage: number; - total: number; - totalPages: number; - hasNext: boolean; - hasPrev: boolean; - }; -}; +import { ListVideosQueryDto } from './dto/list-videos-query.dto'; +import { VideoResponseDto } from './dto/video-response.dto'; +import { VideosService } from './videos.service'; + +import type { PaginatedResponse } from '@shared/types/pagination'; +import { CreateVideoDto } from './dto/create-video-dto'; @Controller('videos') @UseGuards(KeycloakAuthGuard) @@ -63,6 +56,12 @@ export class VideosController { }); } + @Post() + @Roles('user', 'admin') + async create(@Body() body: CreateVideoDto): Promise { + await this.videosService.createNewVideo(body); + } + @Get(':id') async get(@Param('id') id: string): Promise { return this.videosService.get(Number(id)); diff --git a/src/modules/videos/videos.service.ts b/src/modules/videos/videos.service.ts index 346a6e1..3d8350f 100644 --- a/src/modules/videos/videos.service.ts +++ b/src/modules/videos/videos.service.ts @@ -1,4 +1,6 @@ import axios from 'axios'; +import isEmpty from 'lodash/isEmpty'; + import { Injectable } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; @@ -9,6 +11,7 @@ import { VideoResponseDto } from './dto/video-response.dto'; import { PaginatedResponse } from '@shared/dto/paginated'; import { ListVideosQueryDto } from './dto/list-videos-query.dto'; import { VideoMetadataDto } from '@shared/dto/video-metadata'; +import { CreateVideoDto } from './dto/create-video-dto'; @Injectable() export class VideosService { @@ -133,6 +136,20 @@ export class VideosService { } } + async createNewVideo(data: CreateVideoDto): Promise { + const { url, videoid } = data; + + const searchUrl = await this.prisma.videos.findFirst({ + where: { url, videoid }, + }); + + if (!isEmpty(searchUrl)) { + throw new Error( + `Esta video já foi renderizado, e se encontra na situação ${searchUrl.situation}`, + ); + } + } + async update(id: number, data: Prisma.videosUpdateInput): Promise { return this.prisma.videos.update({ where: { id }, diff --git a/src/shared/types/pagination.ts b/src/shared/types/pagination.ts new file mode 100644 index 0000000..5bee215 --- /dev/null +++ b/src/shared/types/pagination.ts @@ -0,0 +1,11 @@ +export interface PaginatedResponse { + content: T[]; + pagination: { + page: number; + perPage: number; + total: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; + }; +}