Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion apps/api/src/youtube/youtube.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, ParseBoolPipe, Post, Query, Req, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { SupabaseAuthGuard } from '../guards/auth.guard';
import { YoutubeService } from './youtube.service';
Expand Down Expand Up @@ -35,4 +35,22 @@ export class YoutubeController {
maxResults ? Math.min(parseInt(maxResults, 10), 24) : 6,
);
}

@Get('trained-videos')
getTrainedVideos(@Req() req: AuthRequest) {
const userId = getUserId(req);
return this.youtubeService.getTrainedVideos(userId);
}

@Post('trained-videos')
saveTrainedVideos(@Req() req: AuthRequest, @Body() data: any) {
const userId = getUserId(req);
return this.youtubeService.saveTrainedVideos(userId, data.videos);
}

@Get('channel-stats')
getChannelStats(@Req() req: AuthRequest, @Query('forceSync', ParseBoolPipe) forceSync?: boolean) {
const userId = getUserId(req);
return this.youtubeService.getChannelStats(userId, forceSync);
}
}
268 changes: 242 additions & 26 deletions apps/api/src/youtube/youtube.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,10 @@ import {
import { ConfigService } from '@nestjs/config';
import { SupabaseService } from '../supabase/supabase.service';
import axios from 'axios';
import type { ChannelVideoItem, ChannelStatsItem, ChannelStats } from '@repo/validation';

const YT_REGEX = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;

export interface ChannelVideoItem {
id: string;
title: string;
thumbnail: string;
publishedAt: string;
viewCount: number;
}

@Injectable()
export class YoutubeService {
private readonly supabase;
Expand Down Expand Up @@ -72,13 +65,11 @@ export class YoutubeService {
throw new NotFoundException('YouTube channel not found. Please connect your channel first.');
}

console.error('Supabase channel lookup result:', { channel_id: channel.channel_id, has_provider_token: !!channel.provider_token, has_refresh_token: !!channel.refresh_token, supabase_error: error });

const accessToken = await this.resolveAccessToken(userId, channel);

const searchParams: Record<string, string | number | boolean> = {
const searchParams: Record<string, string | number> = {
part: 'snippet',
forMine: true,
forMine: 'true',
type: 'video',
order: 'viewCount',
maxResults,
Expand All @@ -89,8 +80,7 @@ export class YoutubeService {
params: searchParams,
headers: { Authorization: `Bearer ${accessToken}` },
timeout: 15000,
}).catch((error) => {
console.error('YouTube search API error:', error.response?.data || error.message);
}).catch(() => {
throw new InternalServerErrorException('Failed to fetch videos from YouTube');
});

Expand All @@ -104,10 +94,7 @@ export class YoutubeService {
params: { part: 'statistics', id: videoIds },
headers: { Authorization: `Bearer ${accessToken}` },
timeout: 15000,
}).catch((error) => {
console.error('YouTube stats API error:', error.response?.data || error.message);
return null;
});
}).catch(() => null);

const statsMap = new Map<string, number>();
if (statsRes?.data?.items) {
Expand All @@ -131,6 +118,240 @@ export class YoutubeService {
return { videos, nextPageToken: searchRes.data.nextPageToken };
}

async getTrainedVideos(userId: string): Promise<ChannelVideoItem[]> {
const { data: channel, error } = await this.supabase
.from('youtube_channels')
.select('youtube_trained_videos')
.eq('user_id', userId)
.single();

if (error || !channel) {
throw new NotFoundException('Channel not found.');
}

return (channel.youtube_trained_videos as ChannelVideoItem[]) || [];
}

async saveTrainedVideos(userId: string, videos: ChannelVideoItem[]): Promise<{ message: string }> {
const { error } = await this.supabase
.from('youtube_channels')
.update({ youtube_trained_videos: videos })
.eq('user_id', userId);

if (error) {
throw new InternalServerErrorException('Failed to save trained videos');
}

return { message: 'Trained videos saved successfully' };
}

async getChannelStats(userId: string, forceSync?: boolean): Promise<ChannelStats> {

console.log('forceSync', forceSync);

if (forceSync) {
const { data: usageData, error: usageError } = await this.supabase.rpc('use_feature', {
p_user_id: userId,
});
if (usageError) {
throw new InternalServerErrorException('Failed to get feature usage');
}
if (!usageData.allowed) {
throw new BadRequestException(usageData.message);
}

const { data: channel, error } = await this.supabase
.from('youtube_channels')
.select('channel_id, provider_token, refresh_token')
.eq('user_id', userId)
.single();

if (error || !channel) {
throw new NotFoundException('YouTube channel not found. Please connect your channel first.');
}

const accessToken = await this.resolveAccessToken(userId, channel);

// Fetch channel-level stats
const channelRes = await axios.get('https://www.googleapis.com/youtube/v3/channels', {
params: { part: 'snippet,statistics', mine: 'true' },
headers: { Authorization: `Bearer ${accessToken}` },
timeout: 15000,
}).catch(() => {
throw new InternalServerErrorException('Failed to fetch channel info from YouTube');
});

const channelItem = channelRes.data.items?.[0];
if (!channelItem) {
throw new NotFoundException('Channel data not found.');
}

const channelSnippet = channelItem.snippet;
const channelStats = channelItem.statistics;

// Fetch top videos by view count (up to 5)
const topSearchRes = await axios.get('https://www.googleapis.com/youtube/v3/search', {
params: {
part: 'snippet',
forMine: 'true',
type: 'video',
order: 'viewCount',
maxResults: 5,
},
headers: { Authorization: `Bearer ${accessToken}` },
timeout: 15000,
}).catch(() => {
throw new InternalServerErrorException('Failed to fetch top videos from YouTube');
});

// Fetch recent videos (up to 5)
const recentSearchRes = await axios.get('https://www.googleapis.com/youtube/v3/search', {
params: {
part: 'snippet',
forMine: 'true',
type: 'video',
order: 'date',
maxResults: 5,
},
headers: { Authorization: `Bearer ${accessToken}` },
timeout: 15000,
}).catch(() => null);

const allVideoIds = new Set<string>();
(topSearchRes.data.items || []).forEach((i: any) => allVideoIds.add(i.id.videoId));
(recentSearchRes?.data?.items || []).forEach((i: any) => allVideoIds.add(i.id.videoId));

const videoDetailsRes = allVideoIds.size > 0
? await axios.get('https://www.googleapis.com/youtube/v3/videos', {
params: { part: 'statistics,contentDetails', id: Array.from(allVideoIds).join(',') },
headers: { Authorization: `Bearer ${accessToken}` },
timeout: 15000,
}).catch(() => null)
: null;

type VideoDetail = { statistics: { viewCount: string; likeCount: string; commentCount: string }; contentDetails: { duration: string } };
const detailsMap = new Map<string, VideoDetail>();
if (videoDetailsRes?.data?.items) {
for (const v of videoDetailsRes.data.items) {
detailsMap.set(v.id, v);
}
}

const mapItem = (item: any): ChannelStatsItem => {
const snippet = item.snippet;
const thumb = snippet.thumbnails?.high || snippet.thumbnails?.medium || snippet.thumbnails?.default;
const detail = detailsMap.get(item.id.videoId);
return {
id: item.id.videoId,
title: snippet.title,
thumbnail: thumb?.url || '',
publishedAt: snippet.publishedAt,
viewCount: parseInt(detail?.statistics?.viewCount || '0', 10),
likeCount: parseInt(detail?.statistics?.likeCount || '0', 10),
commentCount: parseInt(detail?.statistics?.commentCount || '0', 10),
duration: detail?.contentDetails?.duration || '',
};
};

const topVideos: ChannelStatsItem[] = (topSearchRes.data.items || []).map(mapItem);
const recentVideos: ChannelStatsItem[] = (recentSearchRes?.data?.items || []).map(mapItem);

const totalViews = parseInt(channelStats.viewCount || '0', 10);
const totalVideos = parseInt(channelStats.videoCount || '0', 10);
const totalLikes = topVideos.reduce((sum, v) => sum + v.likeCount, 0);

const { data: updatedChannel, error: updateError } = await this.supabase.from('youtube_channels').update({
channel_name: channelSnippet.title,
subscriber_count: parseInt(channelStats.subscriberCount || '0', 10),
view_count: totalViews,
video_count: totalVideos,
top_videos: topVideos,
recent_videos: recentVideos,
last_synced_at: new Date().toISOString(),

}).eq('user_id', userId)
.select('custom_url, country, default_language, thumbnail')
.single();

if (updateError) {
throw new InternalServerErrorException('Failed to update channel stats');
}

return {
channelName: channelSnippet.title,
subscriberCount: parseInt(channelStats.subscriberCount || '0', 10),
totalViews,
totalVideos,
topVideos,
recentVideos,
avgViewsPerVideo: totalVideos > 0 ? Math.round(totalViews / totalVideos) : 0,
avgLikesPerVideo: topVideos.length > 0 ? Math.round(totalLikes / topVideos.length) : 0,
cooldown_minutes: usageData.cooldown_minutes,
can_use_now: usageData.can_use_now,
plan: usageData.plan,
remaining: usageData.remaining,
daily_limit: usageData.daily_limit,
usage_count: usageData.usage_count,
cooldown_remaining: usageData.cooldown_remaining,
custom_url: updatedChannel?.custom_url || '',
country: updatedChannel?.country,
default_language: updatedChannel?.default_language,
thumbnail: updatedChannel?.thumbnail || '',
};
}

const { data: channel, error: channelError } = await this.supabase
.from('youtube_channels')
.select(
'channel_name, subscriber_count, view_count, video_count, top_videos, recent_videos, last_synced_at, usage_count, custom_url, country, default_language, thumbnail ',
)
.eq('user_id', userId)
.single();
if (channelError) {
throw new InternalServerErrorException('Failed to get channel stats');
}
if (!channel) {
throw new NotFoundException('Channel not found');
}

const { data: usageData, error: usageError } = await this.supabase.rpc('get_feature_usage', {
p_user_id: userId,
});

if (usageError) {
throw new InternalServerErrorException('Failed to get feature usage');
}
// console.log('usageData', usageData);

const topVideos: ChannelStatsItem[] = (channel.top_videos ?? []) as ChannelStatsItem[];
const recentVideos: ChannelStatsItem[] = (channel.recent_videos ?? []) as ChannelStatsItem[];
const totalLikes = topVideos.reduce((sum, v) => sum + (v.likeCount ?? 0), 0);

return {
channelName: channel.channel_name,
subscriberCount: channel.subscriber_count,
totalViews: channel.view_count,
totalVideos: channel.video_count,
topVideos,
recentVideos,
avgViewsPerVideo: channel.video_count > 0 ? Math.round(channel.view_count / channel.video_count) : 0,
avgLikesPerVideo: topVideos.length > 0 ? Math.round(totalLikes / topVideos.length) : 0,
cooldown_minutes: usageData.cooldown_minutes,
can_use_now: usageData.can_use_now,
plan: usageData.plan,
remaining: usageData.remaining,
daily_limit: usageData.daily_limit,
usage_count: usageData.usage_count,
cooldown_remaining: usageData.cooldown_remaining,
custom_url: channel.custom_url,
country: channel.country,
default_language: channel.default_language,
thumbnail: channel.thumbnail,

};

}

private async resolveAccessToken(
userId: string,
channel: { provider_token: string; refresh_token?: string },
Expand All @@ -156,17 +377,12 @@ export class YoutubeService {
}

try {
const params = new URLSearchParams({
const tokenRes = await axios.post('https://oauth2.googleapis.com/token', {
client_id: clientId,
client_secret: clientSecret,
refresh_token: channel.refresh_token,
grant_type: 'refresh_token',
});

const tokenRes = await axios.post('https://oauth2.googleapis.com/token', params.toString(), {
timeout: 15000,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
}, { timeout: 15000, headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });

const newToken = tokenRes.data.access_token;

Expand All @@ -180,4 +396,4 @@ export class YoutubeService {
throw new BadRequestException('YouTube connection expired. Please reconnect your channel.');
}
}
}
}
Loading
Loading