Initial commit from Specify template
This commit is contained in:
33
backend/src/modules/thumbnails/thumbnails.controller.ts
Normal file
33
backend/src/modules/thumbnails/thumbnails.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
38
backend/src/modules/thumbnails/thumbnails.module.ts
Normal file
38
backend/src/modules/thumbnails/thumbnails.module.ts
Normal 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 {}
|
||||
55
backend/src/modules/thumbnails/thumbnails.service.ts
Normal file
55
backend/src/modules/thumbnails/thumbnails.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
24
backend/src/modules/youtube/youtube.controller.ts
Normal file
24
backend/src/modules/youtube/youtube.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
16
backend/src/modules/youtube/youtube.module.ts
Normal file
16
backend/src/modules/youtube/youtube.module.ts
Normal 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 {}
|
||||
201
backend/src/modules/youtube/youtube.service.ts
Normal file
201
backend/src/modules/youtube/youtube.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user