├── .env.example ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .prettierrc ├── .swcrc ├── .tool-versions ├── Dockerfile ├── LICENSE ├── README.md ├── compose.yml ├── eslint.config.mjs ├── jest.config.json ├── nest-cli.json ├── package-lock.json ├── package.json ├── src ├── app.module.ts ├── common │ └── response.ts ├── docs │ └── auth-doc.decorator.ts ├── envs │ └── variables.env.ts ├── main.ts └── modules │ ├── auth │ ├── auth.module.ts │ ├── controllers │ │ ├── auth.controller.ts │ │ └── auth.controller.unit.spec.ts │ ├── middlewares │ │ └── auth.middleware.ts │ ├── services │ │ ├── auth.service.ts │ │ └── auth.service.unit.spec.ts │ └── use-cases │ │ ├── create-simple-token.use-case.ts │ │ └── create-simple-token.use-case.unit.spec.ts │ ├── redirects │ ├── controllers │ │ └── redirect.controller.ts │ └── redirect.module.ts │ ├── status │ ├── controllers │ │ └── status.controller.ts │ └── status.module.ts │ └── videos │ ├── controllers │ ├── video.controller.ts │ └── video.controller.unit.spec.ts │ ├── dto │ ├── get-info.controller.dto.ts │ ├── get-video-info.use-case.dto.ts │ ├── get-video.controller.dto.ts │ └── get-video.use-case.dto.ts │ ├── services │ ├── video.service.ts │ └── video.service.unit.spec.ts │ ├── use-cases │ ├── get-video-info.use-case.ts │ ├── get-video-info.use-case.unit.spec.ts │ ├── get-video.use-case.ts │ └── get-video.use-case.unit.spec.ts │ └── video.module.ts ├── test ├── auth │ └── auth.e2e.spec.ts ├── jest-e2e.json └── videos │ └── videos.e2e.spec.ts ├── tsconfig.build.json └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # SET development OR test OR production 2 | NODE_ENV= 3 | 4 | # BY DEFAULT IS 8080 5 | PORT= 6 | 7 | # JWT SETTINGS 8 | JWT_SECRET= 9 | JWT_EXPIRES= 10 | JWT_PASSPHRASE= 11 | 12 | # COOKIES (SET IN JSON FORMAT WITH SINGLE QUOTES, REQUIRED ONLY IN PRODUCTION) 13 | COOKIES= 14 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: vstream-api tests 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '**.js' 7 | - '**.ts' 8 | - '**.json' 9 | - '**.yml' 10 | - 'Dockerfile' 11 | branches: [ "main" ] 12 | 13 | push: 14 | paths: 15 | - '**.js' 16 | - '**.ts' 17 | - '**.json' 18 | - '**.yml' 19 | - 'Dockerfile' 20 | branches: [ "main" ] 21 | 22 | jobs: 23 | tests: 24 | runs-on: ubuntu-latest 25 | strategy: 26 | matrix: 27 | node-version: [22.x] 28 | 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v3 32 | 33 | - name: Setup Node.js ${{ matrix.node-version }} 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: ${{ matrix.node-version }} 37 | 38 | - name: Installing dependencies 39 | run: npm i 40 | 41 | - name: Run unit tests 42 | run: npm test 43 | 44 | - name: Run e2e tests 45 | run: npm run test:e2e -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env* 40 | !.env.example 41 | 42 | # temp directory 43 | .temp 44 | .tmp 45 | 46 | # Runtime data 47 | pids 48 | *.pid 49 | *.seed 50 | *.pid.lock 51 | 52 | # Diagnostic reports (https://nodejs.org/api/report.html) 53 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 54 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript", 5 | "tsx": false, 6 | "decorators": true 7 | }, 8 | "transform": { 9 | "legacyDecorator": true, 10 | "decoratorMetadata": true 11 | }, 12 | "target": "es2023", 13 | "keepClassNames": true 14 | }, 15 | "module": { 16 | "type": "commonjs", 17 | "noInterop": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs v22.14.0 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:22-alpine3.20 AS build 3 | 4 | WORKDIR /app 5 | 6 | COPY . . 7 | 8 | # Development stage 9 | FROM node:22-alpine3.20 AS development 10 | 11 | WORKDIR /app 12 | 13 | COPY --from=build /app /app 14 | 15 | RUN npm install 16 | 17 | RUN apk add ffmpeg 18 | 19 | CMD ["npm", "run", "start:dev"] 20 | 21 | # Development stage 22 | FROM node:22-alpine3.20 AS production 23 | 24 | WORKDIR /app 25 | 26 | COPY --from=build /app /app 27 | 28 | RUN npm ci 29 | 30 | RUN apk add ffmpeg 31 | 32 | RUN npm run build 33 | 34 | CMD ["npm", "run", "start:prod"] 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Educational Use License 2 | 3 | Copyright (c) 2025 Vinicius José 4 | 5 | This software is licensed under the terms of the **Educational Use License**. You are free to use, copy, modify, and distribute the software, **but only for educational purposes**. Commercial use, including but not limited to selling, promoting, or distributing the software for profit, is strictly prohibited. 6 | 7 | Permissions: 8 | - You may **use** the software for **personal learning, academic study, research, or teaching purposes**. 9 | - You may **distribute** copies of the software in its original or modified form, as long as you include this license and the condition that it is for educational use only. 10 | - You may **modify** the software, provided that the modified version is not used for commercial purposes and is distributed with this same license. 11 | 12 | Restrictions: 13 | - **No Commercial Use**: The software may not be used, distributed, or modified for any commercial purpose. "Commercial" refers to any activity aimed at generating profit or promoting products or services for financial gain. 14 | - **Redistribution**: If you redistribute the software, either modified or unmodified, you must do so under the same terms, ensuring that it is clear the software is for educational purposes only. 15 | 16 | No Warranty: 17 | This software is provided "as is", without any warranty of any kind, either express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software. 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vstream-api 2 | 3 | ![Status Badge](https://img.shields.io/badge/status-beta-blue) 4 | 5 | ## Table of Contents 6 | 7 | - [About](#about) 8 | - [Features](#features) 9 | - [Technologies Used](#technologies-used) 10 | - [Installation](#installation) 11 | - [How to Use](#how-to-use) 12 | - [Testing](#testing) 13 | - [License](#license) 14 | - [Contact](#contact) 15 | 16 | ## About 17 | 18 | The **vstream-api** is an API developed to interact with YouTube videos. This project is currently in Beta development status and aims to fetch video information and download them. 19 | 20 | In addition to the code being open source, the API is public, anyone can access it at: https://vstream-api.vinion.dev. 21 | To see all routes, go to: https://vstream-api.vinion.dev/api 22 | 23 | ## Features 24 | 25 | - Retrieve video information. 26 | - Download videos in selected formats. 27 | 28 | ## Technologies Used 29 | 30 | - NestJs 31 | - @distube/ytdl-core 32 | 33 | ## Installation 34 | 35 | To install and run this project locally, follow the steps below: 36 | 37 | 1. Clone the repository: 38 | ```bash 39 | git clone https://github.com/viniciusjosedev/vstream-api.git 40 | ``` 41 | 42 | 2. Navigate to the project directory: 43 | ```bash 44 | cd vstream-api 45 | ``` 46 | 47 | 3. Install dependencies: 48 | ```bash 49 | npm install 50 | ``` 51 | 52 | 4. Configure the environment variables according to the `.env.example` file: 53 | 54 | ```bash 55 | # SET development OR test OR production 56 | NODE_ENV= 57 | 58 | # BY DEFAULT IS 8080 59 | PORT= 60 | 61 | # JWT SETTINGS 62 | JWT_SECRET= 63 | JWT_EXPIRES= 64 | JWT_PASSPHRASE= 65 | 66 | # COOKIES (SET IN JSON FORMAT WITH SINGLE QUOTES, REQUIRED ONLY IN PRODUCTION) 67 | COOKIES= 68 | ``` 69 | 70 | 5. Start the server: 71 | ```bash 72 | npm run start 73 | ``` 74 | Or, if you prefer, start with Docker: 75 | ```bash 76 | npm run docker:up 77 | ``` 78 | 79 | ## How to Use 80 | 81 | Use tools like [Postman](https://www.postman.com/) or [Insomnia](https://insomnia.rest/) to test the available endpoints. After starting the server, you can access the API via the endpoint: 82 | 83 | ``` 84 | http://localhost:8080/ 85 | ``` 86 | 87 | You can see all endpoints with swagger in the /api route: 88 | 89 | ``` 90 | http://localhost:8080/api 91 | ``` 92 | 93 | The basic usage flow starts with getting your token from the /auth/generate-simple-token route. 94 | 95 | ```bash 96 | curl -X POST "http://localhost:8080/auth/generate-simple-token" \ 97 | -H "Content-Type: application/json" \ 98 | -d '{}' 99 | ``` 100 | 101 | The return of this is: 102 | 103 | ```json 104 | { 105 | "success": true, 106 | "data": { 107 | "access_token": "TOKEN" 108 | }, 109 | "statusCode": 201 110 | } 111 | ``` 112 | 113 | You can then get information about any public YouTube video using the /video/info route. To do this, you need to include the token in the request header (Authorization). In addition, the url and fields parameters must be passed as query parameters. Fields can be: title, channel, formats, thumbnail 114 | 115 | ```bash 116 | curl -X GET "http://localhost:8080/video/info?url=youtube.com/watch?v=jNQXAC9IVRw&fields=title,channel,thumbnail,formats" \ 117 | -H "Authorization: Bearer access_token" \ 118 | -H "Content-Type: application/json" 119 | ``` 120 | 121 | The complete return if you pass all the fields is this: 122 | 123 | ```json 124 | { 125 | "success": true, 126 | "statusCode": 200, 127 | "data": { 128 | "title": "Me at the zoo", 129 | "channel": { 130 | "channel_url": "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A", 131 | "name": "jawed", 132 | "photo_url": "https://yt3.ggpht.com/uI3VE4PVqvCy0xnWLqMJnEzyBUm3T8VHOCp4ee-1RxdHqKXCdUE_qXYQnpf9AfuEoIPactVyDhM=s48-c-k-c0x00ffffff-no-rj" 133 | }, 134 | "thumbnail": { 135 | "url": "https://i.ytimg.com/vi/jNQXAC9IVRw/hqdefault.jpg?sqp=-oaymwE2CNACELwBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgBvgKAAvABigIMCAAQARhVIFkoZTAP&rs=AOn4CLB7NY0fx4yZYDj27223V3b7Sowf5w", 136 | "width": 336, 137 | "height": 188 138 | }, 139 | "formats": [ 140 | { 141 | "hasVideo": true, 142 | "hasAudio": true, 143 | "qualityVideo": "240p", 144 | "qualityAudio": "small", 145 | "format": "video/mp4", 146 | "url": "" 147 | }, 148 | "..." 149 | ] 150 | } 151 | } 152 | ``` 153 | 154 | To download the video, you should choose the desired format URLs from the previous request in the "formats" array. Note the following properties: 155 | 156 | - formats: Array with different available formats; 157 | - hasVideo and hasAudio: Indicate whether the format contains video and/or audio. 158 | 159 | It's important to understand that: 160 | 161 | - Some lower quality formats will have both hasVideo: true and hasAudio: true; 162 | - Most higher quality formats will have hasVideo: true but hasAudio: false; 163 | - A few formats will be audio-only (hasVideo: false and hasAudio: true). 164 | 165 | This is why the API allows you to combine custom video and audio selections through the /video/download endpoint. You can send: 166 | 167 | - urlVideo: URL of the chosen video format; 168 | - urlAudio: URL of the chosen audio format. 169 | 170 | Important format details: 171 | 172 | - If you send only the urlAudio parameter, the audio will always be delivered in MP3 format; 173 | - If you send both video and audio parameters or just the video parameter, the video will maintain the same format it had in the previous request; 174 | - If you select a format that already has both video and audio (hasVideo: true and hasAudio: true) as your urlVideo and still provide a urlAudio, the original audio in the video will be replaced by the audio you specified in urlAudio. 175 | 176 | Additional notes: 177 | 178 | - The API uses streaming (sending by chunks); 179 | - Tools like curl and wget may not work properly; 180 | - The X-Content-Length header provides the file size to estimate download time. 181 | 182 | Check the file type to use the correct extension, although in the examples all are saved as .mp4. 183 | JavaScript code is available that can be run in any browser console to facilitate the process. 184 | 185 | ```js 186 | async function downloadFile() { 187 | try { 188 | const url = "http://localhost:8080/video/download"; 189 | const response = await fetch(url, { 190 | method: 'POST', 191 | headers: { 192 | 'Authorization': 'Bearer access_token', 193 | 'Content-Type': 'application/json' 194 | }, 195 | body: JSON.stringify({ 196 | urlVideo: 'https://rr4---sn-p5qlsn6l.googlevideo.com...', 197 | urlAudio: 'https://rr4---sn-p5qlsn6l.googlevideo.com...' 198 | }) 199 | }); 200 | 201 | if (!response.ok) { 202 | throw new Error(`Err: ${response.status} ${response.statusText}`); 203 | } 204 | 205 | const contentLength = response.headers.get("X-Content-Length"); 206 | const totalSize = contentLength ? parseInt(contentLength, 10) : null; 207 | 208 | 209 | console.log(`All size file: ${totalSize ? `${(totalSize / 1024 / 1024).toFixed(2)} MB` : 'Unknown'}`); 210 | 211 | console.log('contentLength', contentLength) 212 | 213 | const reader = response.body.getReader(); 214 | let receivedSize = 0; 215 | const chunks = []; 216 | 217 | while (true) { 218 | const { done, value } = await reader.read(); 219 | if (done) break; 220 | 221 | chunks.push(value); 222 | receivedSize += value.length; 223 | 224 | if (totalSize) { 225 | const progress = ((receivedSize / totalSize) * 100).toFixed(2); 226 | console.log(`Progress: ${progress}% (${(receivedSize / 1024 / 1024).toFixed(2)} MB done)`); 227 | } else { 228 | console.log(`Downloading... (${(receivedSize / 1024 / 1024).toFixed(2)} MB done)`); 229 | } 230 | } 231 | 232 | const blob = new Blob(chunks); 233 | const link = document.createElement("a"); 234 | link.href = URL.createObjectURL(blob); 235 | 236 | const contentDisposition = response.headers.get("Content-Disposition"); 237 | let fileName = "download.mp4"; 238 | 239 | if (contentDisposition) { 240 | const match = contentDisposition.match(/filename="(.+)"/); 241 | if (match && match[1]) { 242 | fileName = match[1]; 243 | } 244 | } 245 | 246 | link.download = fileName; 247 | document.body.appendChild(link); 248 | link.click(); 249 | document.body.removeChild(link); 250 | URL.revokeObjectURL(link.href); 251 | 252 | console.log("✅ Download done!"); 253 | 254 | } catch (error) { 255 | console.error("❌ Err in download file:", error); 256 | } 257 | } 258 | 259 | downloadFile(); 260 | ``` 261 | 262 | Another example, this time in a node js environment (18+). For this example, install the file-type lib, version 16.5.4. 263 | 264 | ```bash 265 | npm i file-type@16.5.4 266 | ``` 267 | 268 | ```js 269 | const fs = require('fs'); 270 | const FileType = require('file-type') 271 | 272 | const urlVideo = 'https://rr4---sn-p5qlsn6l.googlevideo.com...'; 273 | const token = 'access_token'; 274 | 275 | async function downloadFile() { 276 | try { 277 | const response = await fetch('http://localhost:8080/video/download', { 278 | method: 'POST', 279 | headers: { 280 | 'Authorization': `Bearer ${token}`, 281 | 'Content-Type': 'application/json' 282 | }, 283 | body: JSON.stringify({ 284 | urlVideo, 285 | }) 286 | }); 287 | 288 | if (!response.ok) { 289 | console.log(await response.json()); 290 | 291 | throw new Error('Error making the request'); 292 | } 293 | 294 | const arrayBuffer = await response.arrayBuffer(); 295 | 296 | const fileType = await FileType.fromBuffer(arrayBuffer); 297 | 298 | if (!fileType) { 299 | throw new Error("Impossible to get the file type of file."); 300 | } 301 | 302 | let extension = fileType.ext; 303 | 304 | const buffer = Buffer.from(arrayBuffer) 305 | 306 | await fs.promises.writeFile(`file.${extension}`, buffer) 307 | 308 | console.log('Download completed!'); 309 | } catch (error) { 310 | console.error('Error:', error); 311 | } 312 | } 313 | 314 | downloadFile(); 315 | 316 | ``` 317 | 318 | ## Testing 319 | 320 | To run unit tests, use the command: 321 | 322 | ```bash 323 | npm run test 324 | ``` 325 | 326 | For integration tests, use the command: 327 | 328 | ```bash 329 | npm run test:e2e 330 | ``` 331 | 332 | If you want to run the tests inside Docker, use the command: 333 | 334 | ```bash 335 | npm run docker:attach 336 | ``` 337 | 338 | ## License 339 | 340 | This project is licensed under the [Educational Use License](LICENSE). 341 | 342 | ## Contact 343 | 344 | For more information, contact [Vinicius José](mailto:viniciusjosedev@gmail.com). 345 | 346 | --- 347 | 348 | This README template follows best practices recommended by the community. For more details, check out the following resources: 349 | 350 | - [How to Write a Good README](https://blog.rocketseat.com.br/como-fazer-um-bom-readme/) 351 | - [How to Write an Amazing README on Your GitHub - Alura](https://www.alura.com.br/artigos/escrever-bom-readme) 352 | - [A Template for a Good README.md - GitHub Gist](https://gist.github.com/lohhans/f8da0b147550df3f96914d3797e9fb89) 353 | 354 | Additionally, you can watch the following video to better understand how to create an effective README: 355 | 356 | [How to Write a Good README (PT-BR)](https://www.youtube.com/watch?v=k4Rsy8GbKE0) 357 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | networks: 2 | vstream: 3 | driver: bridge 4 | 5 | services: 6 | vstream-api: 7 | build: 8 | context: . 9 | dockerfile: Dockerfile 10 | target: ${NODE_ENV} 11 | container_name: vstream-api 12 | networks: 13 | - vstream 14 | volumes: 15 | - .:/app 16 | ports: 17 | - "${PORT}:${PORT}" 18 | - "9229:9229" 19 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import eslint from '@eslint/js'; 3 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 4 | import globals from 'globals'; 5 | import tseslint from 'typescript-eslint'; 6 | 7 | export default tseslint.config( 8 | { 9 | ignores: ['eslint.config.mjs'], 10 | }, 11 | eslint.configs.recommended, 12 | ...tseslint.configs.recommendedTypeChecked, 13 | eslintPluginPrettierRecommended, 14 | { 15 | languageOptions: { 16 | globals: { 17 | ...globals.node, 18 | ...globals.jest, 19 | }, 20 | ecmaVersion: 5, 21 | sourceType: 'module', 22 | parserOptions: { 23 | projectService: true, 24 | tsconfigRootDir: import.meta.dirname, 25 | }, 26 | }, 27 | }, 28 | { 29 | rules: { 30 | '@typescript-eslint/no-explicit-any': 'off', 31 | '@typescript-eslint/no-floating-promises': 'off', 32 | '@typescript-eslint/no-unsafe-argument': 'warn', 33 | '@typescript-eslint/no-unsafe-return': 'off', 34 | '@typescript-eslint/no-unsafe-call': 'off', 35 | '@typescript-eslint/no-unsafe-assignment': 'off', 36 | '@typescript-eslint/no-unsafe-member-access': 'off', 37 | '@typescript-eslint/no-misused-promises': 'off', 38 | }, 39 | }, 40 | ); -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".unit.spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "@swc/jest" 8 | }, 9 | "moduleNameMapper": { 10 | "^src/(.*)$": "/src/$1" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vstream-api", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest --config ./jest.config.json", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json", 21 | "docker:up": "docker compose up -d --build && npm run docker:logs", 22 | "docker:down": "docker compose down", 23 | "docker:logs": "docker compose logs -f vstream-api", 24 | "docker:attach": "docker exec -it vstream-api sh" 25 | }, 26 | "dependencies": { 27 | "@distube/ytdl-core": "^4.16.4", 28 | "@nestjs/common": "^11.0.1", 29 | "@nestjs/core": "^11.0.1", 30 | "@nestjs/jwt": "^11.0.0", 31 | "@nestjs/platform-express": "^11.0.1", 32 | "@nestjs/swagger": "^11.0.6", 33 | "@nestjs/throttler": "^6.4.0", 34 | "@types/fluent-ffmpeg": "^2.1.27", 35 | "class-transformer": "^0.5.1", 36 | "class-validator": "^0.14.1", 37 | "dotenv": "^16.4.7", 38 | "file-type": "^16.5.4", 39 | "fluent-ffmpeg": "^2.1.3", 40 | "reflect-metadata": "^0.2.2", 41 | "rxjs": "^7.8.1" 42 | }, 43 | "devDependencies": { 44 | "@eslint/eslintrc": "^3.2.0", 45 | "@eslint/js": "^9.18.0", 46 | "@nestjs/cli": "^11.0.0", 47 | "@nestjs/schematics": "^11.0.0", 48 | "@nestjs/testing": "^11.0.1", 49 | "@swc/cli": "^0.6.0", 50 | "@swc/core": "^1.11.9", 51 | "@swc/jest": "^0.2.37", 52 | "@types/express": "^5.0.0", 53 | "@types/jest": "^29.5.14", 54 | "@types/node": "^22.10.7", 55 | "@types/supertest": "^6.0.2", 56 | "eslint": "^9.18.0", 57 | "eslint-config-prettier": "^10.0.1", 58 | "eslint-plugin-prettier": "^5.2.2", 59 | "globals": "^16.0.0", 60 | "jest": "^29.7.0", 61 | "prettier": "^3.4.2", 62 | "source-map-support": "^0.5.21", 63 | "supertest": "^7.0.0", 64 | "ts-jest": "^29.2.6", 65 | "ts-loader": "^9.5.2", 66 | "ts-node": "^10.9.2", 67 | "tsconfig-paths": "^4.2.0", 68 | "typescript": "^5.7.3", 69 | "typescript-eslint": "^8.20.0" 70 | }, 71 | "jest": { 72 | "moduleFileExtensions": [ 73 | "js", 74 | "json", 75 | "ts" 76 | ], 77 | "rootDir": "src", 78 | "testRegex": ".*\\.spec\\.ts$", 79 | "transform": { 80 | "^.+\\.(t|j)s$": "ts-jest" 81 | }, 82 | "collectCoverageFrom": [ 83 | "**/*.(t|j)s" 84 | ], 85 | "coverageDirectory": "../coverage", 86 | "testEnvironment": "node" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthModule } from './modules/auth/auth.module'; 3 | import { VideoModule } from './modules/videos/video.module'; 4 | import { RedirectModule } from './modules/redirects/redirect.module'; 5 | import { StatusModule } from './modules/status/status.module'; 6 | 7 | @Module({ 8 | imports: [AuthModule, VideoModule, RedirectModule, StatusModule], 9 | controllers: [], 10 | providers: [], 11 | }) 12 | export class AppModule {} 13 | -------------------------------------------------------------------------------- /src/common/response.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | CallHandler, 4 | Catch, 5 | ExceptionFilter, 6 | ExecutionContext, 7 | HttpException, 8 | Injectable, 9 | NestInterceptor, 10 | } from '@nestjs/common'; 11 | import { Response } from 'express'; 12 | import { Observable } from 'rxjs'; 13 | import { map } from 'rxjs/operators'; 14 | 15 | interface ResponseFormat { 16 | success: boolean; 17 | data: T; 18 | statusCode: number; 19 | } 20 | 21 | @Injectable() 22 | export class ResponseInterceptor 23 | implements NestInterceptor> 24 | { 25 | intercept( 26 | context: ExecutionContext, 27 | next: CallHandler, 28 | ): Observable> { 29 | return next.handle().pipe( 30 | map((data) => { 31 | if (typeof data === 'object') { 32 | return { 33 | success: true, 34 | data, 35 | statusCode: context.switchToHttp().getResponse() 36 | .statusCode, 37 | }; 38 | } 39 | return data as ResponseFormat; 40 | }), 41 | ); 42 | } 43 | } 44 | 45 | @Catch(HttpException) 46 | @Injectable() 47 | export class ExceptionInterceptor implements ExceptionFilter { 48 | catch(exception: HttpException, host: ArgumentsHost) { 49 | const response = host.switchToHttp().getResponse(); 50 | const status = exception.getStatus(); 51 | 52 | const messageResponse = exception.getResponse(); 53 | let message = 'An error occurred'; 54 | let statusCode = 500; 55 | 56 | if (typeof messageResponse === 'object') { 57 | message = (messageResponse as any)?.message || message; 58 | statusCode = (messageResponse as any)?.statusCode || statusCode; 59 | } 60 | 61 | response.status(status).json({ 62 | message, 63 | statusCode, 64 | success: false, 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/docs/auth-doc.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { 3 | ApiBearerAuth, 4 | ApiUnauthorizedResponse, 5 | ApiInternalServerErrorResponse, 6 | } from '@nestjs/swagger'; 7 | 8 | export function ApiAuth() { 9 | return applyDecorators( 10 | ApiBearerAuth(), 11 | ApiUnauthorizedResponse({ 12 | description: 'Unauthorized - Token is missing or invalid', 13 | schema: { 14 | example: { 15 | message: 'Unauthorized', 16 | statusCode: 401, 17 | success: false, 18 | }, 19 | }, 20 | }), 21 | ApiInternalServerErrorResponse({ 22 | description: 'Internal server error', 23 | schema: { 24 | example: { 25 | message: 'Internal server error', 26 | statusCode: 500, 27 | success: false, 28 | }, 29 | }, 30 | }), 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/envs/variables.env.ts: -------------------------------------------------------------------------------- 1 | export const variablesEnv = { 2 | nodeEnv: process.env.NODE_ENV || 'development', 3 | port: process.env.port || 8080, 4 | jwtSecret: process.env.JWT_SECRET || 'secret', 5 | jwtExpires: process.env.JWT_EXPIRES || '30d', 6 | jwtPassphrase: process.env.JWT_PASSPHRASE, 7 | cookies: process.env.COOKIES || '[]', 8 | }; 9 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from './app.module'; 4 | import { ExceptionInterceptor, ResponseInterceptor } from './common/response'; 5 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 6 | 7 | async function bootstrap() { 8 | const app = await NestFactory.create(AppModule, { 9 | cors: { 10 | origin: '*', 11 | methods: 'GET,POST', 12 | allowedHeaders: 'Content-Type, Authorization, X-Content-Length', 13 | exposedHeaders: 'X-Content-Length, Content-Disposition', 14 | }, 15 | }); 16 | app.useGlobalInterceptors(new ResponseInterceptor()); 17 | app.useGlobalFilters(new ExceptionInterceptor()); 18 | 19 | const config = new DocumentBuilder() 20 | .setTitle('vstream-api') 21 | .setDescription('The vstream-api description') 22 | .setVersion('1.0') 23 | .build(); 24 | 25 | const documentFactory = () => SwaggerModule.createDocument(app, config); 26 | SwaggerModule.setup('api', app, documentFactory); 27 | 28 | await app.listen(process.env.PORT ?? 3000); 29 | } 30 | bootstrap(); 31 | -------------------------------------------------------------------------------- /src/modules/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthController } from './controllers/auth.controller'; 3 | import { CreateSimpleTokenUseCase } from './use-cases/create-simple-token.use-case'; 4 | import { AuthService } from './services/auth.service'; 5 | import { ThrottlerModule } from '@nestjs/throttler'; 6 | 7 | @Module({ 8 | imports: [ 9 | ThrottlerModule.forRoot({ 10 | throttlers: [ 11 | { 12 | ttl: 60, 13 | limit: 10, 14 | }, 15 | ], 16 | }), 17 | ], 18 | controllers: [AuthController], 19 | providers: [CreateSimpleTokenUseCase, AuthService], 20 | }) 21 | export class AuthModule {} 22 | -------------------------------------------------------------------------------- /src/modules/auth/controllers/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post } from '@nestjs/common'; 2 | import { CreateSimpleTokenUseCase } from '../use-cases/create-simple-token.use-case'; 3 | import { ApiResponse, ApiTags } from '@nestjs/swagger'; 4 | 5 | @ApiTags('Auth') 6 | @Controller('auth') 7 | export class AuthController { 8 | constructor( 9 | private readonly createSimpleTokenUseCase: CreateSimpleTokenUseCase, 10 | ) {} 11 | 12 | @Post('generate-simple-token') 13 | @ApiResponse({ 14 | status: 201, 15 | description: 'Token generated successfully', 16 | schema: { 17 | example: { 18 | success: true, 19 | data: { 20 | access_token: 'sample_token_here', 21 | }, 22 | statusCode: 201, 23 | }, 24 | }, 25 | }) 26 | generateSimpleToken(): { access_token: string } { 27 | return { 28 | access_token: this.createSimpleTokenUseCase.execute(), 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/auth/controllers/auth.controller.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from './auth.controller'; 3 | import { CreateSimpleTokenUseCase } from '../use-cases/create-simple-token.use-case'; 4 | import { AuthService } from '../services/auth.service'; 5 | 6 | describe('AuthController', () => { 7 | let authController: AuthController; 8 | let createSimpleTokenUseCase: CreateSimpleTokenUseCase; 9 | 10 | beforeEach(async () => { 11 | const app: TestingModule = await Test.createTestingModule({ 12 | controllers: [AuthController], 13 | providers: [CreateSimpleTokenUseCase, AuthService], 14 | }).compile(); 15 | 16 | authController = app.get(AuthController); 17 | createSimpleTokenUseCase = app.get( 18 | CreateSimpleTokenUseCase, 19 | ); 20 | }); 21 | 22 | afterEach(() => { 23 | jest.clearAllMocks(); 24 | }); 25 | 26 | it('should return object with token', () => { 27 | const createSimpleTokenUseCaseSpy = jest 28 | .spyOn(createSimpleTokenUseCase, 'execute') 29 | .mockReturnValue('token'); 30 | 31 | expect(authController.generateSimpleToken()).toStrictEqual({ 32 | access_token: 'token', 33 | }); 34 | 35 | expect(createSimpleTokenUseCaseSpy).toHaveBeenCalled(); 36 | expect(createSimpleTokenUseCaseSpy).toHaveBeenCalledTimes(1); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/modules/auth/middlewares/auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | UnauthorizedException, 6 | } from '@nestjs/common'; 7 | import { JwtService } from '@nestjs/jwt'; 8 | import { Request } from 'express'; 9 | import { variablesEnv } from 'src/envs/variables.env'; 10 | import crypto from 'crypto'; 11 | 12 | @Injectable() 13 | export class AuthMiddleware implements CanActivate { 14 | constructor(private jwtService: JwtService) {} 15 | 16 | async canActivate(context: ExecutionContext): Promise { 17 | const request = context.switchToHttp().getRequest(); 18 | 19 | const token = request.headers.authorization?.split(' ')[1]; 20 | const signature = request.headers['X-Signature']; 21 | 22 | if (!token) { 23 | throw new UnauthorizedException(); 24 | } 25 | 26 | try { 27 | const payload = await this.jwtService.verifyAsync(token, { 28 | secret: variablesEnv.jwtSecret, 29 | }); 30 | 31 | const { publicKey } = payload; 32 | 33 | if (signature) { 34 | const requestData = { 35 | path: request.path, 36 | body: request.body, 37 | method: request.method, 38 | }; 39 | 40 | const verifier = crypto.createVerify('SHA256'); 41 | verifier.update(JSON.stringify(requestData)).end(); 42 | 43 | const isValid = verifier.verify( 44 | publicKey, 45 | signature as string, 46 | 'base64', 47 | ); 48 | 49 | if (!isValid) { 50 | throw new UnauthorizedException(); 51 | } 52 | } else { 53 | const { passphrase } = payload; 54 | 55 | if (passphrase !== variablesEnv.jwtPassphrase) { 56 | throw new UnauthorizedException(); 57 | } 58 | 59 | return true; 60 | } 61 | } catch { 62 | throw new UnauthorizedException(); 63 | } 64 | 65 | return true; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/modules/auth/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | import { variablesEnv } from 'src/envs/variables.env'; 4 | 5 | @Injectable() 6 | export class AuthService { 7 | private readonly JWT_SECRET = variablesEnv.jwtSecret; 8 | private readonly EXPIRES_IN = variablesEnv.jwtExpires; 9 | 10 | public generateToken(data: any): string { 11 | const jwtService = new JwtService(); 12 | 13 | return jwtService.sign(data, { 14 | secret: this.JWT_SECRET, 15 | algorithm: 'HS256', 16 | expiresIn: this.EXPIRES_IN, 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/auth/services/auth.service.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthService } from '../services/auth.service'; 3 | 4 | jest.mock('@nestjs/jwt', () => { 5 | const actual = jest.requireActual('@nestjs/jwt'); 6 | 7 | return { 8 | ...actual, 9 | JwtService: jest.fn().mockImplementation(() => { 10 | return { 11 | sign: jest.fn().mockReturnValue('token'), 12 | }; 13 | }), 14 | }; 15 | }); 16 | 17 | describe('AuthService', () => { 18 | let authService: AuthService; 19 | 20 | beforeEach(async () => { 21 | const app: TestingModule = await Test.createTestingModule({ 22 | providers: [AuthService], 23 | }).compile(); 24 | 25 | authService = app.get(AuthService); 26 | }); 27 | 28 | afterEach(() => { 29 | jest.clearAllMocks(); 30 | }); 31 | 32 | it('should return token', () => { 33 | expect(authService.generateToken({})).toBe('token'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/modules/auth/use-cases/create-simple-token.use-case.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { variablesEnv } from 'src/envs/variables.env'; 3 | import { AuthService } from '../services/auth.service'; 4 | 5 | @Injectable() 6 | export class CreateSimpleTokenUseCase { 7 | constructor(private readonly authService: AuthService) {} 8 | 9 | public execute() { 10 | return this.authService.generateToken({ 11 | passphrase: variablesEnv.jwtPassphrase, 12 | }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/auth/use-cases/create-simple-token.use-case.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CreateSimpleTokenUseCase } from './create-simple-token.use-case'; 3 | import { AuthService } from '../services/auth.service'; 4 | 5 | describe('CreateSimpleTokenService', () => { 6 | let createSimpleTokenUseCase: CreateSimpleTokenUseCase; 7 | let authService: AuthService; 8 | 9 | beforeEach(async () => { 10 | const app: TestingModule = await Test.createTestingModule({ 11 | providers: [CreateSimpleTokenUseCase, AuthService], 12 | }).compile(); 13 | 14 | createSimpleTokenUseCase = app.get( 15 | CreateSimpleTokenUseCase, 16 | ); 17 | authService = app.get(AuthService); 18 | }); 19 | 20 | afterEach(() => { 21 | jest.clearAllMocks(); 22 | }); 23 | 24 | it('should return token', () => { 25 | const authServiceSpy = jest 26 | .spyOn(authService, 'generateToken') 27 | .mockReturnValue('token'); 28 | 29 | expect(createSimpleTokenUseCase.execute()).toBe('token'); 30 | 31 | expect(authServiceSpy).toHaveBeenCalled(); 32 | expect(authServiceSpy).toHaveBeenCalledTimes(1); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/modules/redirects/controllers/redirect.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Redirect } from '@nestjs/common'; 2 | import { ApiResponse } from '@nestjs/swagger'; 3 | 4 | @Controller() 5 | export class RedirectController { 6 | @Get('/') 7 | @Redirect('https://vstream-docs.vinion.dev', 302) 8 | @ApiResponse({ 9 | status: 302, 10 | description: 'Successful redirect to https://vstream-docs.vinion.dev', 11 | }) 12 | public getDoc() {} 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/redirects/redirect.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { RedirectController } from './controllers/redirect.controller'; 3 | 4 | @Module({ 5 | imports: [], 6 | controllers: [RedirectController], 7 | providers: [], 8 | }) 9 | export class RedirectModule {} 10 | -------------------------------------------------------------------------------- /src/modules/status/controllers/status.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, HttpCode } from '@nestjs/common'; 2 | import { ApiResponse } from '@nestjs/swagger'; 3 | 4 | @Controller() 5 | export class StatusController { 6 | @Get('/status') 7 | @HttpCode(200) 8 | @ApiResponse({ 9 | status: 200, 10 | description: 'Successful response', 11 | schema: { 12 | example: { 13 | success: true, 14 | statusCode: 200, 15 | data: { 16 | online: true, 17 | }, 18 | }, 19 | }, 20 | }) 21 | public getStatus() { 22 | return { online: true }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/status/status.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { StatusController } from './controllers/status.controller'; 3 | 4 | @Module({ 5 | imports: [], 6 | controllers: [StatusController], 7 | providers: [], 8 | }) 9 | export class StatusModule {} 10 | -------------------------------------------------------------------------------- /src/modules/videos/controllers/video.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpCode, 6 | Post, 7 | Query, 8 | Res, 9 | UseGuards, 10 | ValidationPipe, 11 | } from '@nestjs/common'; 12 | import { GetInfoControllerDTO } from '../dto/get-info.controller.dto'; 13 | import { AuthMiddleware } from 'src/modules/auth/middlewares/auth.middleware'; 14 | import { GetVideoInfoUseCase } from '../use-cases/get-video-info.use-case'; 15 | import { GetVideoControllerDTO } from '../dto/get-video.controller.dto'; 16 | import { GetVideoUseCase } from '../use-cases/get-video.use-case'; 17 | import { Response } from 'express'; 18 | import { ApiResponse, ApiTags } from '@nestjs/swagger'; 19 | import { ApiAuth } from 'src/docs/auth-doc.decorator'; 20 | 21 | @ApiTags('Video') 22 | @Controller('/video') 23 | @UseGuards(AuthMiddleware) 24 | export class VideoController { 25 | constructor( 26 | private readonly getVideoInfoUseCase: GetVideoInfoUseCase, 27 | private readonly getVideoUseCase: GetVideoUseCase, 28 | ) {} 29 | 30 | @Get('/info') 31 | @ApiAuth() 32 | @ApiResponse({ 33 | status: 200, 34 | description: 'Successful response', 35 | schema: { 36 | example: { 37 | success: true, 38 | statusCode: 200, 39 | data: { 40 | title: 'Example Video', 41 | channel: { 42 | name: 'name', 43 | photo_url: 'photo_url', 44 | channel_url: 'channel_url', 45 | }, 46 | thumbnail: { 47 | url: 'url', 48 | width: 1920, 49 | height: 1080, 50 | }, 51 | formats: [ 52 | { 53 | hasVideo: true, 54 | hasAudio: true, 55 | qualityVideo: '720p', 56 | qualityAudio: 'medium', 57 | format: 'video/mp4', 58 | url: 'url', 59 | }, 60 | ], 61 | }, 62 | }, 63 | }, 64 | }) 65 | @ApiResponse({ 66 | status: 500, 67 | description: 'Internal server error', 68 | schema: { 69 | example: { 70 | success: false, 71 | message: 'Something went wrong', 72 | statusCode: 500, 73 | }, 74 | }, 75 | }) 76 | @HttpCode(200) 77 | public getInfo( 78 | @Query( 79 | new ValidationPipe({ 80 | forbidUnknownValues: true, 81 | transform: true, 82 | }), 83 | ) 84 | query: GetInfoControllerDTO, 85 | ) { 86 | return this.getVideoInfoUseCase.execute(query); 87 | } 88 | 89 | @Post('/download') 90 | @ApiAuth() 91 | @ApiResponse({ 92 | status: 200, 93 | description: 'Successful response, returns data as a chunked Buffer', 94 | content: { 95 | 'application/octet-stream': { 96 | schema: { 97 | type: 'string', 98 | format: 'binary', 99 | }, 100 | }, 101 | }, 102 | }) 103 | @HttpCode(200) 104 | public getVideo( 105 | @Body( 106 | new ValidationPipe({ 107 | forbidUnknownValues: true, 108 | transform: true, 109 | }), 110 | ) 111 | body: GetVideoControllerDTO, 112 | @Res() 113 | res: Response, 114 | ) { 115 | return this.getVideoUseCase.execute({ 116 | res, 117 | ...body, 118 | }); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/modules/videos/controllers/video.controller.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { VideoController } from './video.controller'; 3 | import { GetVideoInfoUseCase } from '../use-cases/get-video-info.use-case'; 4 | import { JwtService } from '@nestjs/jwt'; 5 | import { ValidFields } from '../dto/get-info.controller.dto'; 6 | import { VideoService } from '../services/video.service'; 7 | import { GetVideoUseCase } from '../use-cases/get-video.use-case'; 8 | import { Response } from 'express'; 9 | 10 | jest.mock('@nestjs/jwt', () => { 11 | const actual = jest.requireActual('@nestjs/jwt'); 12 | 13 | return { 14 | ...actual, 15 | JwtService: jest.fn().mockImplementation(() => { 16 | return { 17 | sign: jest.fn().mockReturnValue('token'), 18 | }; 19 | }), 20 | }; 21 | }); 22 | 23 | describe('VideoController', () => { 24 | let videoController: VideoController; 25 | let getVideoInfoUseCase: GetVideoInfoUseCase; 26 | let getVideoUseCase: GetVideoUseCase; 27 | 28 | beforeEach(async () => { 29 | const app: TestingModule = await Test.createTestingModule({ 30 | imports: [], 31 | controllers: [VideoController], 32 | providers: [ 33 | GetVideoInfoUseCase, 34 | GetVideoUseCase, 35 | JwtService, 36 | VideoService, 37 | ], 38 | }).compile(); 39 | 40 | videoController = app.get(VideoController); 41 | getVideoInfoUseCase = app.get(GetVideoInfoUseCase); 42 | getVideoUseCase = app.get(GetVideoUseCase); 43 | }); 44 | 45 | afterEach(() => { 46 | jest.clearAllMocks(); 47 | }); 48 | 49 | describe('getInfo method', () => { 50 | it('should return video infos', async () => { 51 | const getVideoInfoMock = { 52 | channel: { 53 | channel_url: 'url', 54 | name: 'name', 55 | photo_url: 'url', 56 | }, 57 | formats: [ 58 | { 59 | format: 'video/mp4', 60 | hasAudio: true, 61 | hasVideo: true, 62 | qualityAudio: 'medium', 63 | qualityVideo: '720p', 64 | url: 'url', 65 | }, 66 | ], 67 | thumbnail: { 68 | height: 1080, 69 | width: 1920, 70 | url: 'url', 71 | }, 72 | title: 'title', 73 | }; 74 | 75 | const getVideoInfoUseCaseSpy = jest 76 | .spyOn(getVideoInfoUseCase, 'execute') 77 | .mockResolvedValue(getVideoInfoMock); 78 | 79 | expect( 80 | await videoController.getInfo({ 81 | fields: [ 82 | ValidFields.TITLE, 83 | ValidFields.CHANNEL, 84 | ValidFields.THUMBNAIL, 85 | ValidFields.FORMATS, 86 | ], 87 | url: 'url', 88 | }), 89 | ).toStrictEqual(getVideoInfoMock); 90 | 91 | expect(getVideoInfoUseCaseSpy).toHaveBeenCalled(); 92 | expect(getVideoInfoUseCaseSpy).toHaveBeenCalledTimes(1); 93 | }); 94 | }); 95 | 96 | describe('getVideo method', () => { 97 | it('should return undefined', async () => { 98 | const getVideoUseCaseSpy = jest 99 | .spyOn(getVideoUseCase, 'execute') 100 | .mockResolvedValue(); 101 | 102 | expect( 103 | await videoController.getVideo( 104 | { 105 | urlVideo: 'url', 106 | }, 107 | {} as Response, 108 | ), 109 | ).toBeUndefined(); 110 | 111 | expect(getVideoUseCaseSpy).toHaveBeenCalled(); 112 | expect(getVideoUseCaseSpy).toHaveBeenCalledTimes(1); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/modules/videos/dto/get-info.controller.dto.ts: -------------------------------------------------------------------------------- 1 | import { ArrayMinSize, IsArray, IsEnum, IsNotEmpty } from 'class-validator'; 2 | import { Transform } from 'class-transformer'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | 5 | export enum ValidFields { 6 | THUMBNAIL = 'thumbnail', 7 | FORMATS = 'formats', 8 | TITLE = 'title', 9 | CHANNEL = 'channel', 10 | } 11 | 12 | export class GetInfoControllerDTO { 13 | @ApiProperty({ required: true }) 14 | @IsNotEmpty() 15 | url: string; 16 | 17 | @ApiProperty({ 18 | required: true, 19 | description: 'Fields to include in the response', 20 | enum: ValidFields, 21 | isArray: true, 22 | example: ['title', 'formats'], 23 | }) 24 | @IsNotEmpty() 25 | @IsArray() 26 | @ArrayMinSize(1) 27 | @Transform(({ value }) => 28 | typeof value === 'string' ? value.split(',') : value, 29 | ) 30 | @IsEnum(ValidFields, { each: true }) 31 | fields: ValidFields[]; 32 | } 33 | -------------------------------------------------------------------------------- /src/modules/videos/dto/get-video-info.use-case.dto.ts: -------------------------------------------------------------------------------- 1 | import * as ytdl from '@distube/ytdl-core'; 2 | 3 | export interface GetVideoInfoUseCaseDTO { 4 | url: string; 5 | fields: Array<'thumbnail' | 'formats' | 'title' | 'channel'>; 6 | } 7 | 8 | export interface FormatsFiltered { 9 | hasVideo: boolean; 10 | hasAudio: boolean; 11 | qualityVideo: string; 12 | qualityAudio: string; 13 | format: string; 14 | url: string; 15 | } 16 | 17 | export interface VideoInfoUseCaseDTO { 18 | title?: string; 19 | channel?: { 20 | photo_url: string; 21 | name: string; 22 | channel_url: string; 23 | }; 24 | formats?: FormatsFiltered[]; 25 | thumbnail?: ytdl.thumbnail; 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/videos/dto/get-video.controller.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, ValidateIf } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { Transform } from 'class-transformer'; 4 | 5 | export class GetVideoControllerDTO { 6 | @ApiProperty({ 7 | required: false, 8 | description: 'Video URL (required if url not provided)', 9 | }) 10 | @Transform((value) => { 11 | return typeof value.value === 'string' 12 | ? decodeURIComponent(value.value) 13 | : value; 14 | }) 15 | @ValidateIf((o) => !o.urlAudio) 16 | @IsNotEmpty({ 17 | message: 'One of the fields urlVideo or urlAudio must be provided', 18 | }) 19 | urlVideo?: string; 20 | 21 | @ApiProperty({ required: false, description: 'Audio URL (optional)' }) 22 | @ValidateIf((o) => !o.urlVideo) 23 | @IsNotEmpty({ 24 | message: 'One of the fields urlVideo or urlAudio must be provided', 25 | }) 26 | urlAudio?: string; 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/videos/dto/get-video.use-case.dto.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express'; 2 | 3 | export interface GetVideoUseCaseDTO { 4 | urlVideo?: string; 5 | urlAudio?: string; 6 | res: Response; 7 | } 8 | -------------------------------------------------------------------------------- /src/modules/videos/services/video.service.ts: -------------------------------------------------------------------------------- 1 | import * as ytdl from '@distube/ytdl-core'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { variablesEnv } from 'src/envs/variables.env'; 4 | 5 | @Injectable() 6 | export class VideoService { 7 | private readonly agent: ytdl.Agent; 8 | 9 | constructor() { 10 | this.agent = ytdl.createAgent(JSON.parse(variablesEnv.cookies)); 11 | } 12 | 13 | public async getFormats(url: string) { 14 | return (await ytdl.getInfo(url, { agent: this.agent })).formats; 15 | } 16 | 17 | public async getInfo(url: string) { 18 | return ytdl.getBasicInfo(url, { agent: this.agent }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/videos/services/video.service.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { VideoService } from '../services/video.service'; 3 | 4 | jest.mock('@distube/ytdl-core', () => { 5 | return { 6 | createAgent: jest.fn().mockReturnThis(), 7 | getInfo: jest.fn().mockResolvedValue({ 8 | formats: [], 9 | }), 10 | getBasicInfo: jest.fn().mockResolvedValue({ 11 | videoDetails: {}, 12 | }), 13 | }; 14 | }); 15 | 16 | describe('GetVideoInfo', () => { 17 | let videoService: VideoService; 18 | 19 | beforeEach(async () => { 20 | const app: TestingModule = await Test.createTestingModule({ 21 | imports: [], 22 | controllers: [], 23 | providers: [VideoService], 24 | }).compile(); 25 | 26 | videoService = app.get(VideoService); 27 | }); 28 | 29 | afterEach(() => { 30 | jest.clearAllMocks(); 31 | }); 32 | 33 | it('should return formats', async () => { 34 | expect(await videoService.getFormats('url')).toStrictEqual([]); 35 | }); 36 | 37 | it('should return getInfo', async () => { 38 | expect(await videoService.getInfo('url')).toStrictEqual({ 39 | videoDetails: {}, 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/modules/videos/use-cases/get-video-info.use-case.ts: -------------------------------------------------------------------------------- 1 | import { VideoService } from '../services/video.service'; 2 | import { 3 | FormatsFiltered, 4 | GetVideoInfoUseCaseDTO, 5 | VideoInfoUseCaseDTO, 6 | } from '../dto/get-video-info.use-case.dto'; 7 | import { Injectable } from '@nestjs/common'; 8 | 9 | @Injectable() 10 | export class GetVideoInfoUseCase { 11 | constructor(private readonly videoService: VideoService) {} 12 | 13 | private getFirstNumbersInString(text: string): string | null { 14 | const numbers = text.match(/^\d+/); 15 | return numbers ? numbers[0] : null; 16 | } 17 | 18 | private async isUrlAccessible(url: string): Promise { 19 | try { 20 | const response = await fetch(url, { 21 | method: 'HEAD', 22 | headers: { 23 | 'User-Agent': 'Mozilla/5.0', 24 | }, 25 | }); 26 | 27 | return response.ok; 28 | } catch (error) { 29 | console.error('Error in access a URL:', error); 30 | return false; 31 | } 32 | } 33 | 34 | private async getFormatsFiltered(url: string) { 35 | const allowFormats = ['240', '360', '480', '720', '1080']; 36 | const data: FormatsFiltered[] = []; 37 | 38 | const formats = await this.videoService.getFormats(url); 39 | 40 | await Promise.all( 41 | formats.map(async (format) => { 42 | const quality = 43 | this.getFirstNumbersInString(format.qualityLabel || '') || 'null'; 44 | const hasValidUrl = await this.isUrlAccessible(format.url); 45 | 46 | if ( 47 | (allowFormats.includes(quality) && hasValidUrl) || 48 | (format.hasAudio && !format.hasVideo && hasValidUrl) 49 | ) { 50 | data.push({ 51 | hasVideo: format.hasVideo, 52 | hasAudio: format.hasAudio, 53 | qualityVideo: format.qualityLabel, 54 | qualityAudio: format.quality, 55 | format: format.mimeType?.split(';')[0] as string, 56 | url: format.url, 57 | }); 58 | } 59 | }), 60 | ); 61 | 62 | return data; 63 | } 64 | 65 | public async execute({ 66 | url, 67 | fields, 68 | }: GetVideoInfoUseCaseDTO): Promise { 69 | const data: VideoInfoUseCaseDTO = {}; 70 | 71 | const { videoDetails } = await this.videoService.getInfo(url); 72 | 73 | await Promise.all( 74 | fields.map(async (field) => { 75 | switch (field) { 76 | case 'title': 77 | data.title = videoDetails.title; 78 | break; 79 | case 'channel': 80 | data.channel = { 81 | channel_url: videoDetails.author.channel_url, 82 | name: videoDetails.author.name, 83 | photo_url: videoDetails.author.avatar, 84 | }; 85 | break; 86 | case 'thumbnail': 87 | data.thumbnail = 88 | videoDetails.thumbnails[videoDetails.thumbnails.length - 1]; 89 | break; 90 | case 'formats': 91 | data.formats = await this.getFormatsFiltered(url); 92 | } 93 | }), 94 | ); 95 | 96 | return data; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/modules/videos/use-cases/get-video-info.use-case.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { GetVideoInfoUseCase } from '../use-cases/get-video-info.use-case'; 3 | import { VideoService } from '../services/video.service'; 4 | import { ValidFields } from '../dto/get-info.controller.dto'; 5 | 6 | jest.mock('@nestjs/jwt', () => { 7 | const actual = jest.requireActual('@nestjs/jwt'); 8 | 9 | return { 10 | ...actual, 11 | JwtService: jest.fn().mockImplementation(() => { 12 | return { 13 | sign: jest.fn().mockReturnValue('token'), 14 | }; 15 | }), 16 | }; 17 | }); 18 | 19 | describe('GetVideoInfo', () => { 20 | let getVideoInfoUseCase: GetVideoInfoUseCase; 21 | let videoService: VideoService; 22 | 23 | beforeEach(async () => { 24 | const app: TestingModule = await Test.createTestingModule({ 25 | imports: [], 26 | controllers: [], 27 | providers: [GetVideoInfoUseCase, VideoService], 28 | }).compile(); 29 | 30 | getVideoInfoUseCase = app.get(GetVideoInfoUseCase); 31 | videoService = app.get(VideoService); 32 | }); 33 | 34 | afterEach(() => { 35 | jest.clearAllMocks(); 36 | }); 37 | 38 | it('should return video infos', async () => { 39 | const getVideoInfoMock = { 40 | channel: { 41 | channel_url: 'channel_url', 42 | name: 'name', 43 | photo_url: 'avatar_url', 44 | }, 45 | formats: [ 46 | { 47 | format: 'video/mp4', 48 | hasAudio: true, 49 | hasVideo: true, 50 | qualityAudio: 'medium', 51 | qualityVideo: '720p', 52 | url: 'url', 53 | }, 54 | ], 55 | thumbnail: { 56 | height: 1080, 57 | width: 1920, 58 | url: 'url', 59 | }, 60 | title: 'title', 61 | }; 62 | 63 | const getInfoSpy = jest.spyOn(videoService, 'getInfo').mockResolvedValue({ 64 | videoDetails: { 65 | title: 'title', 66 | author: { 67 | channel_url: 'channel_url', 68 | name: 'name', 69 | avatar: 'avatar_url', 70 | }, 71 | thumbnails: [ 72 | { 73 | height: 1080, 74 | width: 1920, 75 | url: 'url', 76 | }, 77 | ], 78 | }, 79 | } as any); 80 | 81 | const getFormatsSpy = jest 82 | .spyOn(videoService, 'getFormats') 83 | .mockResolvedValue([ 84 | { 85 | hasAudio: true, 86 | hasVideo: true, 87 | qualityLabel: '720p', 88 | quality: 'medium', 89 | url: 'url', 90 | mimeType: 'video/mp4; codcs', 91 | }, 92 | ] as any); 93 | 94 | const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ 95 | ok: true, 96 | } as any); 97 | 98 | expect( 99 | await getVideoInfoUseCase.execute({ 100 | fields: [ 101 | ValidFields.TITLE, 102 | ValidFields.CHANNEL, 103 | ValidFields.THUMBNAIL, 104 | ValidFields.FORMATS, 105 | ], 106 | url: 'url', 107 | }), 108 | ).toStrictEqual(getVideoInfoMock); 109 | 110 | expect(getInfoSpy).toHaveBeenCalled(); 111 | expect(getInfoSpy).toHaveBeenCalledTimes(1); 112 | 113 | expect(getFormatsSpy).toHaveBeenCalled(); 114 | expect(getFormatsSpy).toHaveBeenCalledTimes(1); 115 | 116 | expect(fetchSpy).toHaveBeenCalled(); 117 | expect(fetchSpy).toHaveBeenCalledTimes(1); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /src/modules/videos/use-cases/get-video.use-case.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { GetVideoUseCaseDTO } from '../dto/get-video.use-case.dto'; 3 | import * as path from 'path'; 4 | import * as fs from 'fs'; 5 | import * as Ffmpeg from 'fluent-ffmpeg'; 6 | import * as FileType from 'file-type'; 7 | import core from 'file-type/core'; 8 | 9 | @Injectable() 10 | export class GetVideoUseCase { 11 | private pathTempFolder = path.resolve( 12 | __dirname, 13 | '..', 14 | '..', 15 | '..', 16 | '..', 17 | 'temp', 18 | ); 19 | 20 | private async createTempFolder() { 21 | await fs.promises.mkdir(this.pathTempFolder, { recursive: true }); 22 | } 23 | 24 | private async processNextChunk({ 25 | contentLength, 26 | startByte, 27 | url, 28 | }: { 29 | startByte: number; 30 | contentLength: number; 31 | url: string; 32 | }) { 33 | const chunkSize = 1048576; 34 | 35 | if (startByte >= contentLength) { 36 | return null; 37 | } 38 | 39 | const endByte = Math.min(startByte + chunkSize - 1, contentLength - 1); 40 | 41 | const chunkResponse = await fetch(url, { 42 | headers: { 43 | Range: `bytes=${startByte}-${endByte}`, 44 | 'User-Agent': 45 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 46 | Referer: 'https://www.youtube.com/', 47 | Accept: '*/*', 48 | 'Accept-Language': 'en-US,en;q=0.9', 49 | Connection: 'keep-alive', 50 | }, 51 | }); 52 | 53 | if (!chunkResponse.ok && chunkResponse.status !== 206) { 54 | throw new Error(`HTTP Error: ${chunkResponse.status}`); 55 | } 56 | 57 | const buffer = Buffer.from(await chunkResponse.arrayBuffer()); 58 | 59 | return { buffer, startByte: endByte + 1 }; 60 | } 61 | 62 | private async saveFile(url: string) { 63 | await this.createTempFolder(); 64 | 65 | let startByte = 0; 66 | 67 | const headResponse = await fetch(url, { method: 'HEAD' }); 68 | const contentLength = parseInt( 69 | headResponse.headers.get('Content-Length') || '0', 70 | ); 71 | 72 | if (contentLength === 0) { 73 | throw new Error('Could not determine video size'); 74 | } 75 | 76 | const nameFile = new Date().getTime(); 77 | const pathFile = path.resolve(this.pathTempFolder, `${nameFile}`); 78 | 79 | const writeStream = fs.createWriteStream(pathFile); 80 | 81 | while (true) { 82 | const chunk = await this.processNextChunk({ 83 | contentLength, 84 | startByte, 85 | url, 86 | }); 87 | 88 | if (chunk === null) { 89 | writeStream.end(); 90 | break; 91 | } 92 | 93 | startByte = chunk.startByte; 94 | 95 | writeStream.write(chunk.buffer); 96 | } 97 | 98 | return pathFile; 99 | } 100 | 101 | private async deleteFile(path: string) { 102 | await fs.promises.unlink(path); 103 | } 104 | 105 | private async convertToMp3(path: string) { 106 | const ffmpeg = Ffmpeg(); 107 | const pathSplit = path.split('/'); 108 | const nameFile = `${pathSplit[pathSplit.length - 1]}.mp3`; 109 | 110 | await new Promise((resolve, reject) => { 111 | ffmpeg 112 | .addInput(path) 113 | .audioCodec('libmp3lame') 114 | .format('mp3') 115 | .on('end', () => { 116 | resolve(true); 117 | }) 118 | .on('error', (err) => { 119 | reject(err); 120 | }) 121 | .save(nameFile); 122 | }); 123 | 124 | this.deleteFile(path); 125 | 126 | return nameFile; 127 | } 128 | 129 | private async returnWithVideoEdited({ 130 | res, 131 | urlVideo, 132 | urlAudio, 133 | }: GetVideoUseCaseDTO) { 134 | const ffmpeg = Ffmpeg(); 135 | 136 | const headResponse = await fetch(urlVideo as string, { method: 'HEAD' }); 137 | const contentLength = parseInt( 138 | headResponse.headers.get('Content-Length') || '0', 139 | ); 140 | 141 | if (contentLength === 0) { 142 | throw new Error('Could not determine video size'); 143 | } 144 | 145 | const [videoPath, audioPath] = await Promise.all([ 146 | await this.saveFile(urlVideo as string), 147 | await this.saveFile(urlAudio as string), 148 | ]); 149 | 150 | const fileType = await FileType.fromFile(videoPath); 151 | 152 | if (!fileType) { 153 | throw new Error('Type not found'); 154 | } 155 | 156 | const { ext, mime } = fileType; 157 | 158 | const nameFile = `${new Date().getTime()}.${ext}`; 159 | const pathFile = path.resolve(this.pathTempFolder, nameFile); 160 | 161 | await new Promise((resolve, reject) => { 162 | ffmpeg 163 | .addInput(videoPath) 164 | .addInput(audioPath) 165 | .outputOption(['-map 0:v', '-map 1:a', '-c:v copy']) 166 | .on('error', (err) => { 167 | this.deleteFile(videoPath); 168 | this.deleteFile(audioPath); 169 | 170 | reject(err); 171 | }) 172 | .on('end', () => { 173 | this.deleteFile(videoPath); 174 | this.deleteFile(audioPath); 175 | 176 | resolve(true); 177 | }) 178 | .save(pathFile); 179 | }); 180 | 181 | const processedStats = fs.statSync(pathFile); 182 | res.setHeader('Content-Type', mime); 183 | res.setHeader('X-Content-Length', processedStats.size.toString()); 184 | 185 | fs.createReadStream(pathFile) 186 | .pipe(res) 187 | .on('close', () => { 188 | this.deleteFile(pathFile); 189 | 190 | res.end(); 191 | }); 192 | } 193 | 194 | private async returnWithoutVideoEdited({ 195 | res, 196 | urlVideo, 197 | }: GetVideoUseCaseDTO) { 198 | const headResponse = await fetch(urlVideo as string, { method: 'HEAD' }); 199 | 200 | if (!headResponse.ok) { 201 | throw new Error('Error in request with urlVideo'); 202 | } 203 | 204 | const contentLength = parseInt( 205 | headResponse.headers.get('Content-Length') || '0', 206 | ); 207 | const contentType = headResponse.headers.get('Content-Type') || ''; 208 | 209 | if (contentLength === 0) { 210 | throw new Error('Could not determine video size'); 211 | } 212 | 213 | res.setHeader('Content-Type', contentType); 214 | res.setHeader('X-Content-Length', contentLength.toString()); 215 | 216 | let startByte = 0; 217 | 218 | while (true) { 219 | const chunk = await this.processNextChunk({ 220 | contentLength, 221 | startByte, 222 | url: urlVideo as string, 223 | }); 224 | 225 | if (chunk === null) { 226 | break; 227 | } 228 | 229 | startByte = chunk.startByte; 230 | 231 | res.write(chunk.buffer); 232 | } 233 | 234 | res.end(); 235 | } 236 | 237 | private async returnOnlyAudio({ res, urlAudio }: GetVideoUseCaseDTO) { 238 | const headResponse = await fetch(urlAudio as string, { method: 'HEAD' }); 239 | 240 | if (!headResponse.ok) { 241 | throw new Error('Error in request with urlAudio'); 242 | } 243 | 244 | const contentLength = parseInt( 245 | headResponse.headers.get('Content-Length') || '0', 246 | ); 247 | 248 | if (contentLength === 0) { 249 | throw new Error('Could not determine audio size'); 250 | } 251 | 252 | const audioPath = await this.saveFile(urlAudio as string); 253 | const audioMp3Path = await this.convertToMp3(audioPath); 254 | 255 | const processedStats = fs.statSync(audioMp3Path); 256 | 257 | const fileType = (await FileType.fromFile( 258 | audioMp3Path, 259 | )) as core.FileTypeResult; 260 | 261 | res.setHeader('Content-Type', fileType.mime); 262 | res.setHeader('X-Content-Length', processedStats.size.toString()); 263 | 264 | fs.createReadStream(audioMp3Path) 265 | .pipe(res) 266 | .on('close', () => { 267 | this.deleteFile(audioMp3Path); 268 | 269 | res.end(); 270 | }); 271 | } 272 | 273 | public async execute(data: GetVideoUseCaseDTO): Promise { 274 | if (data.urlVideo && data.urlAudio) await this.returnWithVideoEdited(data); 275 | else if (!data.urlVideo && data.urlAudio) await this.returnOnlyAudio(data); 276 | else if (data.urlVideo) await this.returnWithoutVideoEdited(data); 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /src/modules/videos/use-cases/get-video.use-case.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { Response } from 'express'; 3 | import { GetVideoUseCase } from './get-video.use-case'; 4 | 5 | jest.mock('@nestjs/jwt', () => { 6 | const actual = jest.requireActual('@nestjs/jwt'); 7 | 8 | return { 9 | ...actual, 10 | JwtService: jest.fn().mockImplementation(() => { 11 | return { 12 | sign: jest.fn().mockReturnValue('token'), 13 | }; 14 | }), 15 | }; 16 | }); 17 | 18 | describe('GetVideoUseCase', () => { 19 | let getVideoUseCase: GetVideoUseCase; 20 | 21 | beforeEach(async () => { 22 | const app: TestingModule = await Test.createTestingModule({ 23 | imports: [], 24 | controllers: [], 25 | providers: [GetVideoUseCase], 26 | }).compile(); 27 | 28 | getVideoUseCase = app.get(GetVideoUseCase); 29 | }); 30 | 31 | afterEach(() => { 32 | jest.clearAllMocks(); 33 | }); 34 | 35 | it('should error if response is not ok', () => { 36 | const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ 37 | ok: false, 38 | } as any); 39 | 40 | expect(async () => { 41 | await getVideoUseCase.execute({ 42 | res: {} as Response, 43 | urlVideo: 'url', 44 | }); 45 | }).rejects.toThrow('Error in request with urlVideo'); 46 | 47 | expect(fetchSpy).toHaveBeenCalled(); 48 | expect(fetchSpy).toHaveBeenCalledTimes(1); 49 | }); 50 | 51 | it('should write chunks in response', async () => { 52 | const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ 53 | ok: true, 54 | body: new ReadableStream({ 55 | start(controller) { 56 | const encoder = new TextEncoder(); 57 | const chunk = encoder.encode('Simulando um corpo de resposta'); 58 | controller.enqueue(chunk); 59 | controller.close(); 60 | }, 61 | }), 62 | headers: { 63 | get: (param) => { 64 | if (param === 'Content-Length') return '1'; 65 | else return 'video/mp4'; 66 | }, 67 | }, 68 | arrayBuffer: () => new ArrayBuffer(1), 69 | } as any); 70 | 71 | const res = { 72 | setHeader: () => {}, 73 | end: () => {}, 74 | on: () => {}, 75 | write: () => {}, 76 | once: () => {}, 77 | emit: () => {}, 78 | status: () => {}, 79 | } as unknown as Response; 80 | 81 | expect( 82 | await getVideoUseCase.execute({ 83 | res: res, 84 | urlVideo: 'url', 85 | }), 86 | ).toBeUndefined(); 87 | 88 | expect(fetchSpy).toHaveBeenCalled(); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/modules/videos/video.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { VideoController } from './controllers/video.controller'; 3 | import { GetVideoInfoUseCase } from './use-cases/get-video-info.use-case'; 4 | import { JwtService } from '@nestjs/jwt'; 5 | import { VideoService } from './services/video.service'; 6 | import { GetVideoUseCase } from './use-cases/get-video.use-case'; 7 | import { ThrottlerModule } from '@nestjs/throttler'; 8 | 9 | @Module({ 10 | imports: [ 11 | ThrottlerModule.forRoot({ 12 | throttlers: [ 13 | { 14 | ttl: 60000, 15 | limit: 10, 16 | }, 17 | ], 18 | }), 19 | ], 20 | controllers: [VideoController], 21 | providers: [GetVideoInfoUseCase, JwtService, VideoService, GetVideoUseCase], 22 | }) 23 | export class VideoModule {} 24 | -------------------------------------------------------------------------------- /test/auth/auth.e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { App } from 'supertest/types'; 5 | import { AppModule } from 'src/app.module'; 6 | import { ResponseInterceptor } from 'src/common/response'; 7 | 8 | describe('AuthRouter (e2e)', () => { 9 | let app: INestApplication; 10 | 11 | beforeEach(async () => { 12 | const moduleFixture: TestingModule = await Test.createTestingModule({ 13 | imports: [AppModule], 14 | }).compile(); 15 | 16 | app = moduleFixture.createNestApplication(); 17 | app.useGlobalInterceptors(new ResponseInterceptor()); 18 | await app.init(); 19 | }); 20 | 21 | describe('/auth/generate-simple-token (POST)', () => { 22 | it('should return token', async () => { 23 | const response = await request(app.getHttpServer()).post( 24 | '/auth/generate-simple-token', 25 | ); 26 | 27 | const { body } = response; 28 | 29 | expect(body).toHaveProperty(['data', 'access_token']); 30 | expect(body).toHaveProperty('success', true); 31 | expect(body).toHaveProperty('statusCode', 201); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e.spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "@swc/jest" 8 | }, 9 | "moduleNameMapper": { 10 | "^src/(.*)$": "/../src/$1" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/videos/videos.e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { App } from 'supertest/types'; 5 | import { AppModule } from 'src/app.module'; 6 | import { ResponseInterceptor } from 'src/common/response'; 7 | 8 | const GET_BASIC_INFO_MOCK = { 9 | videoDetails: { 10 | title: 'title', 11 | author: { 12 | channel_url: 'channel_url', 13 | name: 'name', 14 | avatar: 'avatar_url', 15 | }, 16 | thumbnails: [ 17 | { 18 | height: 1080, 19 | width: 1920, 20 | url: 'url', 21 | }, 22 | ], 23 | }, 24 | }; 25 | 26 | const GET_INFO = { 27 | formats: [ 28 | { 29 | hasAudio: true, 30 | hasVideo: true, 31 | qualityVideo: '720p', 32 | qualityAudio: 'medium', 33 | url: 'url', 34 | format: 'video/mp4', 35 | }, 36 | ], 37 | }; 38 | 39 | jest.mock('@distube/ytdl-core', () => { 40 | return { 41 | createAgent: jest.fn().mockReturnThis(), 42 | getInfo: jest.fn().mockResolvedValue({ 43 | formats: [ 44 | { 45 | hasAudio: true, 46 | hasVideo: true, 47 | qualityLabel: '720p', 48 | quality: 'medium', 49 | url: 'url', 50 | mimeType: 'video/mp4; codcs', 51 | }, 52 | ], 53 | }), 54 | getBasicInfo: jest.fn().mockResolvedValue({ 55 | videoDetails: { 56 | title: 'title', 57 | author: { 58 | channel_url: 'channel_url', 59 | name: 'name', 60 | avatar: 'avatar_url', 61 | }, 62 | thumbnails: [ 63 | { 64 | height: 1080, 65 | width: 1920, 66 | url: 'url', 67 | }, 68 | ], 69 | }, 70 | }), 71 | }; 72 | }); 73 | 74 | describe('VideosRouter (e2e)', () => { 75 | let app: INestApplication; 76 | let access_token: string; 77 | 78 | beforeAll(async () => { 79 | const moduleFixture: TestingModule = await Test.createTestingModule({ 80 | imports: [AppModule], 81 | }).compile(); 82 | 83 | app = moduleFixture.createNestApplication(); 84 | app.useGlobalInterceptors(new ResponseInterceptor()); 85 | await app.init(); 86 | 87 | access_token = ( 88 | await request(app.getHttpServer()).post('/auth/generate-simple-token') 89 | ).body.data.access_token; 90 | }); 91 | 92 | describe('/video/info (GET)', () => { 93 | it('should error if Authorization header is empty', async () => { 94 | const response = await request(app.getHttpServer()) 95 | .get('/video/info') 96 | .query({ 97 | url: 'url', 98 | fields: 'title', 99 | }); 100 | 101 | const { body } = response; 102 | 103 | expect(body).toHaveProperty('message', 'Unauthorized'); 104 | expect(body).toHaveProperty('statusCode', 401); 105 | }); 106 | 107 | it('should error if url query params is empty', async () => { 108 | const response = await request(app.getHttpServer()) 109 | .get('/video/info') 110 | .set('Authorization', `Bearer ${access_token}`) 111 | .query({ 112 | fields: 'title', 113 | }); 114 | 115 | const { body } = response; 116 | 117 | expect(body).toHaveProperty('error', 'Bad Request'); 118 | expect(body).toHaveProperty('message', ['url should not be empty']); 119 | expect(body).toHaveProperty('statusCode', 400); 120 | }); 121 | 122 | it('should return based fields', async () => { 123 | const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ 124 | ok: true, 125 | } as any); 126 | 127 | const { body: bodyOnlyTitle } = await request(app.getHttpServer()) 128 | .get('/video/info') 129 | .set('Authorization', `Bearer ${access_token}`) 130 | .query({ 131 | url: 'url', 132 | fields: 'title', 133 | }); 134 | 135 | const { body: bodyOnlyChannel } = await request(app.getHttpServer()) 136 | .get('/video/info') 137 | .set('Authorization', `Bearer ${access_token}`) 138 | .query({ 139 | url: 'url', 140 | fields: 'channel', 141 | }); 142 | 143 | const { body: bodyOnlyThumbnail } = await request(app.getHttpServer()) 144 | .get('/video/info') 145 | .set('Authorization', `Bearer ${access_token}`) 146 | .query({ 147 | url: 'url', 148 | fields: 'thumbnail', 149 | }); 150 | 151 | const { body: bodyOnlyFormats } = await request(app.getHttpServer()) 152 | .get('/video/info') 153 | .set('Authorization', `Bearer ${access_token}`) 154 | .query({ 155 | url: 'url', 156 | fields: 'formats', 157 | }); 158 | 159 | const { body: bodyAllFields } = await request(app.getHttpServer()) 160 | .get('/video/info') 161 | .set('Authorization', `Bearer ${access_token}`) 162 | .query({ 163 | url: 'url', 164 | fields: 'title,channel,thumbnail,formats', 165 | }); 166 | 167 | expect(bodyOnlyTitle).toHaveProperty('success', true); 168 | expect(bodyOnlyTitle).toHaveProperty('statusCode', 200); 169 | expect(bodyOnlyTitle).toHaveProperty(['data', 'title'], 'title'); 170 | 171 | expect(bodyOnlyChannel).toHaveProperty('success', true); 172 | expect(bodyOnlyChannel).toHaveProperty('statusCode', 200); 173 | expect(bodyOnlyChannel).toHaveProperty(['data', 'channel'], { 174 | channel_url: GET_BASIC_INFO_MOCK.videoDetails.author.channel_url, 175 | name: GET_BASIC_INFO_MOCK.videoDetails.author.name, 176 | photo_url: GET_BASIC_INFO_MOCK.videoDetails.author.avatar, 177 | }); 178 | 179 | expect(bodyOnlyThumbnail).toHaveProperty('success', true); 180 | expect(bodyOnlyThumbnail).toHaveProperty('statusCode', 200); 181 | expect(bodyOnlyThumbnail).toHaveProperty(['data', 'thumbnail'], { 182 | ...GET_BASIC_INFO_MOCK.videoDetails.thumbnails[0], 183 | }); 184 | 185 | expect(bodyOnlyFormats).toHaveProperty('success', true); 186 | expect(bodyOnlyFormats).toHaveProperty('statusCode', 200); 187 | expect(bodyOnlyFormats).toHaveProperty( 188 | ['data', 'formats'], 189 | GET_INFO.formats, 190 | ); 191 | expect(bodyAllFields).toHaveProperty('success', true); 192 | expect(bodyAllFields).toHaveProperty('statusCode', 200); 193 | expect(bodyOnlyTitle).toHaveProperty(['data', 'title'], 'title'); 194 | expect(bodyOnlyChannel).toHaveProperty(['data', 'channel'], { 195 | channel_url: GET_BASIC_INFO_MOCK.videoDetails.author.channel_url, 196 | name: GET_BASIC_INFO_MOCK.videoDetails.author.name, 197 | photo_url: GET_BASIC_INFO_MOCK.videoDetails.author.avatar, 198 | }); 199 | expect(bodyOnlyThumbnail).toHaveProperty(['data', 'thumbnail'], { 200 | ...GET_BASIC_INFO_MOCK.videoDetails.thumbnails[0], 201 | }); 202 | expect(bodyOnlyFormats).toHaveProperty( 203 | ['data', 'formats'], 204 | GET_INFO.formats, 205 | ); 206 | 207 | fetchSpy.mockClear(); 208 | }); 209 | }); 210 | 211 | describe('/video/download (POST)', () => { 212 | it('should error if Authorization header is empty', async () => { 213 | const response = await request(app.getHttpServer()) 214 | .post('/video/download') 215 | .send({ 216 | urlVideo: 'url', 217 | }); 218 | 219 | const { body } = response; 220 | 221 | expect(body).toHaveProperty('message', 'Unauthorized'); 222 | expect(body).toHaveProperty('statusCode', 401); 223 | }); 224 | 225 | it('should error if url body is empty', async () => { 226 | const response = await request(app.getHttpServer()) 227 | .post('/video/download') 228 | .set('Authorization', `Bearer ${access_token}`); 229 | 230 | const { body } = response; 231 | 232 | expect(body).toHaveProperty('error', 'Bad Request'); 233 | expect(body).toHaveProperty('message', [ 234 | 'One of the fields urlVideo or urlAudio must be provided', 235 | 'One of the fields urlVideo or urlAudio must be provided', 236 | ]); 237 | expect(body).toHaveProperty('statusCode', 400); 238 | }); 239 | 240 | it('should return file', async () => { 241 | const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ 242 | ok: true, 243 | body: new ReadableStream({ 244 | start(controller) { 245 | const encoder = new TextEncoder(); 246 | const chunk = encoder.encode('Simulando um corpo de resposta'); 247 | controller.enqueue(chunk); 248 | controller.close(); 249 | }, 250 | }), 251 | headers: { 252 | get: (param) => { 253 | if (param === 'Content-Length') { 254 | return '12312'; 255 | } 256 | }, 257 | }, 258 | arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(1024)), 259 | } as any); 260 | 261 | const response = await request(app.getHttpServer()) 262 | .post('/video/download') 263 | .send({ 264 | urlVideo: 'url', 265 | }) 266 | .set('Authorization', `Bearer ${access_token}`); 267 | 268 | const { status } = response; 269 | 270 | expect(status).toBe(200); 271 | 272 | expect(fetchSpy).toHaveBeenCalled(); 273 | expect(fetchSpy).toHaveBeenCalledTimes(2); 274 | 275 | fetchSpy.mockClear(); 276 | }); 277 | }); 278 | }); 279 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2023", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "noImplicitAny": false, 18 | "strictBindCallApply": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | --------------------------------------------------------------------------------