Initial commit from Specify template

This commit is contained in:
2026-01-29 08:34:11 -03:00
commit fe2c861007
74 changed files with 22234 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
import {
Controller,
Post,
Delete,
Param,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ThumbnailsService } from './thumbnails.service';
@Controller('thumbnails')
export class ThumbnailsController {
constructor(private readonly thumbnailsService: ThumbnailsService) {}
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async upload(@UploadedFile() file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('No file uploaded');
}
const thumbnail = await this.thumbnailsService.create(file);
return this.thumbnailsService.formatThumbnailResponse(thumbnail);
}
@Delete(':id')
async remove(@Param('id') id: string) {
await this.thumbnailsService.remove(id);
return { success: true };
}
}

View File

@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MulterModule } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { v4 as uuidv4 } from 'uuid';
import { ThumbnailsController } from './thumbnails.controller';
import { ThumbnailsService } from './thumbnails.service';
import { Thumbnail } from '../../entities/thumbnail.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Thumbnail]),
MulterModule.register({
storage: diskStorage({
destination: './uploads',
filename: (req, file, callback) => {
const uniqueName = `${uuidv4()}${extname(file.originalname)}`;
callback(null, uniqueName);
},
}),
fileFilter: (req, file, callback) => {
const allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
if (allowedMimes.includes(file.mimetype)) {
callback(null, true);
} else {
callback(new Error('Invalid file type'), false);
}
},
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
},
}),
],
controllers: [ThumbnailsController],
providers: [ThumbnailsService],
})
export class ThumbnailsModule {}

View File

@@ -0,0 +1,55 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { unlink } from 'fs/promises';
import { join } from 'path';
import { Thumbnail } from '../../entities/thumbnail.entity';
@Injectable()
export class ThumbnailsService {
constructor(
@InjectRepository(Thumbnail)
private thumbnailRepository: Repository<Thumbnail>,
) {}
async create(file: Express.Multer.File): Promise<Thumbnail> {
const thumbnail = this.thumbnailRepository.create({
filePath: file.filename,
originalName: file.originalname,
title: file.originalname.replace(/\.[^/.]+$/, ''),
});
return this.thumbnailRepository.save(thumbnail);
}
async findOne(id: string): Promise<Thumbnail> {
const thumbnail = await this.thumbnailRepository.findOne({ where: { id } });
if (!thumbnail) {
throw new NotFoundException(`Thumbnail with ID ${id} not found`);
}
return thumbnail;
}
async remove(id: string): Promise<void> {
const thumbnail = await this.findOne(id);
// Delete file from disk
try {
await unlink(join('./uploads', thumbnail.filePath));
} catch (error) {
console.error('Error deleting file:', error);
}
await this.thumbnailRepository.remove(thumbnail);
}
formatThumbnailResponse(thumbnail: Thumbnail) {
return {
id: thumbnail.id,
url: `/uploads/${thumbnail.filePath}`,
originalName: thumbnail.originalName,
title: thumbnail.title,
position: thumbnail.position,
};
}
}

View File

@@ -0,0 +1,24 @@
import { Controller, Get, Query, BadRequestException } from '@nestjs/common';
import { YouTubeService, YouTubeVideoResponse } from './youtube.service';
@Controller('youtube')
export class YouTubeController {
constructor(private readonly youtubeService: YouTubeService) {}
@Get('search')
async search(
@Query('q') query: string,
@Query('maxResults') maxResults?: string,
): Promise<YouTubeVideoResponse[]> {
if (!query || query.trim().length === 0) {
throw new BadRequestException('Search query is required');
}
const results = await this.youtubeService.search(
query.trim(),
maxResults ? parseInt(maxResults, 10) : 10,
);
return results;
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HttpModule } from '@nestjs/axios';
import { YouTubeController } from './youtube.controller';
import { YouTubeService } from './youtube.service';
import { YouTubeCache } from '../../entities/youtube-cache.entity';
@Module({
imports: [
TypeOrmModule.forFeature([YouTubeCache]),
HttpModule,
],
controllers: [YouTubeController],
providers: [YouTubeService],
})
export class YouTubeModule {}

View File

@@ -0,0 +1,201 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThan } from 'typeorm';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import { YouTubeCache } from '../../entities/youtube-cache.entity';
interface YouTubeSearchItem {
id: { videoId: string };
snippet: {
title: string;
channelTitle: string;
publishedAt: string;
thumbnails: {
medium: { url: string };
high: { url: string };
};
};
}
export interface YouTubeVideoResponse {
videoId: string;
title: string;
channelTitle: string;
thumbnailUrl: string;
publishedAt: string;
viewCount?: string;
}
interface YouTubeSearchResponse {
items: YouTubeSearchItem[];
}
interface YouTubeStatsResponse {
items: Array<{
id: string;
statistics: {
viewCount: string;
};
}>;
}
@Injectable()
export class YouTubeService {
private readonly apiKey: string;
private readonly cacheHours = 24;
constructor(
private configService: ConfigService,
private httpService: HttpService,
@InjectRepository(YouTubeCache)
private cacheRepository: Repository<YouTubeCache>,
) {
this.apiKey = this.configService.get<string>('YOUTUBE_API_KEY', '');
}
async search(query: string, maxResults = 10): Promise<YouTubeVideoResponse[]> {
// Check cache first
const cached = await this.getFromCache(query);
if (cached) {
return cached;
}
// If no API key, return mock data for development
if (!this.apiKey || this.apiKey === 'YOUR_YOUTUBE_API_KEY_HERE') {
return this.getMockResults(query, maxResults);
}
// Fetch from YouTube API
const results = await this.fetchFromYouTube(query, maxResults);
// Save to cache
await this.saveToCache(query, results);
return results;
}
private async getFromCache(query: string): Promise<YouTubeVideoResponse[] | null> {
const cached = await this.cacheRepository.findOne({
where: {
searchQuery: query.toLowerCase(),
expiresAt: MoreThan(new Date()),
},
});
return cached ? cached.results : null;
}
private async saveToCache(query: string, results: YouTubeVideoResponse[]): Promise<void> {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + this.cacheHours);
const cache = this.cacheRepository.create({
searchQuery: query.toLowerCase(),
results,
expiresAt,
});
await this.cacheRepository.save(cache);
}
private async fetchFromYouTube(query: string, maxResults: number): Promise<YouTubeVideoResponse[]> {
const searchUrl = 'https://www.googleapis.com/youtube/v3/search';
const { data } = await firstValueFrom(
this.httpService.get<YouTubeSearchResponse>(searchUrl, {
params: {
part: 'snippet',
q: query,
type: 'video',
maxResults,
key: this.apiKey,
},
}),
);
const videoIds = data.items.map((item: YouTubeSearchItem) => item.id.videoId).join(',');
// Get view counts
const statsUrl = 'https://www.googleapis.com/youtube/v3/videos';
const { data: statsData } = await firstValueFrom(
this.httpService.get<YouTubeStatsResponse>(statsUrl, {
params: {
part: 'statistics',
id: videoIds,
key: this.apiKey,
},
}),
);
const viewCounts = new Map<string, string>();
statsData.items.forEach((item) => {
viewCounts.set(item.id, item.statistics.viewCount);
});
return data.items.map((item: YouTubeSearchItem) => ({
videoId: item.id.videoId,
title: item.snippet.title,
channelTitle: item.snippet.channelTitle,
thumbnailUrl: item.snippet.thumbnails.high?.url || item.snippet.thumbnails.medium.url,
publishedAt: item.snippet.publishedAt,
viewCount: viewCounts.get(item.id.videoId),
}));
}
private getMockResults(query: string, maxResults: number): YouTubeVideoResponse[] {
const mockVideos: YouTubeVideoResponse[] = [
{
videoId: 'mock1',
title: `${query} - Complete Tutorial for Beginners`,
channelTitle: 'Tech Academy',
thumbnailUrl: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg',
publishedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
viewCount: '1250000',
},
{
videoId: 'mock2',
title: `Learn ${query} in 30 Minutes`,
channelTitle: 'Code Master',
thumbnailUrl: 'https://i.ytimg.com/vi/9bZkp7q19f0/hqdefault.jpg',
publishedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
viewCount: '890000',
},
{
videoId: 'mock3',
title: `${query} Crash Course 2025`,
channelTitle: 'Dev Tutorial',
thumbnailUrl: 'https://i.ytimg.com/vi/kJQP7kiw5Fk/hqdefault.jpg',
publishedAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(),
viewCount: '2100000',
},
{
videoId: 'mock4',
title: `Why ${query} is Amazing`,
channelTitle: 'Tech Reviews',
thumbnailUrl: 'https://i.ytimg.com/vi/RgKAFK5djSk/hqdefault.jpg',
publishedAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(),
viewCount: '450000',
},
{
videoId: 'mock5',
title: `${query} Tips and Tricks`,
channelTitle: 'Pro Tips',
thumbnailUrl: 'https://i.ytimg.com/vi/fJ9rUzIMcZQ/hqdefault.jpg',
publishedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
viewCount: '320000',
},
{
videoId: 'mock6',
title: `${query} for Professionals`,
channelTitle: 'Advanced Learning',
thumbnailUrl: 'https://i.ytimg.com/vi/09R8_2nJtjg/hqdefault.jpg',
publishedAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString(),
viewCount: '780000',
},
];
return mockVideos.slice(0, maxResults);
}
}