postiz/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts

344 lines
9.3 KiB
TypeScript

import {
AnalyticsData,
AuthTokenDetails,
PostDetails,
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { google, youtube_v3 } from 'googleapis';
import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client';
import axios from 'axios';
import { YoutubeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/youtube.settings.dto';
import {
BadBody,
SocialAbstract,
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
import * as process from 'node:process';
import dayjs from 'dayjs';
import { GaxiosResponse } from 'gaxios/build/src/common';
import Schema$Video = youtube_v3.Schema$Video;
const clientAndYoutube = () => {
const client = new google.auth.OAuth2({
clientId: process.env.YOUTUBE_CLIENT_ID,
clientSecret: process.env.YOUTUBE_CLIENT_SECRET,
redirectUri: `${process.env.FRONTEND_URL}/integrations/social/youtube`,
});
const youtube = (newClient: OAuth2Client) =>
google.youtube({
version: 'v3',
auth: newClient,
});
const youtubeAnalytics = (newClient: OAuth2Client) =>
google.youtubeAnalytics({
version: 'v2',
auth: newClient,
});
const oauth2 = (newClient: OAuth2Client) =>
google.oauth2({
version: 'v2',
auth: newClient,
});
return { client, youtube, oauth2, youtubeAnalytics };
};
export class YoutubeProvider extends SocialAbstract implements SocialProvider {
override maxConcurrentJob = 1; // YouTube has strict upload quotas
identifier = 'youtube';
name = 'YouTube';
isBetweenSteps = false;
scopes = [
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/youtube',
'https://www.googleapis.com/auth/youtube.force-ssl',
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/youtube.upload',
'https://www.googleapis.com/auth/youtubepartner',
'https://www.googleapis.com/auth/yt-analytics.readonly',
];
editor = 'normal' as const;
override handleErrors(body: string):
| {
type: 'refresh-token' | 'bad-body';
value: string;
}
| undefined {
if (body.includes('invalidTitle')) {
return {
type: 'bad-body',
value:
'We have uploaded your video but we could not set the title. Title is too long.',
};
}
if (body.includes('failedPrecondition')) {
return {
type: 'bad-body',
value:
'We have uploaded your video but we could not set the thumbnail. Thumbnail size is too large.',
};
}
if (body.includes('uploadLimitExceeded')) {
return {
type: 'bad-body',
value:
'You have reached your daily upload limit, please try again tomorrow.',
};
}
if (body.includes('youtubeSignupRequired')) {
return {
type: 'bad-body',
value:
'You have to link your youtube account to your google account first.',
};
}
if (body.includes('youtube.thumbnail')) {
return {
type: 'bad-body',
value:
'Your account is not verified, we have uploaded your video but we could not set the thumbnail. Please verify your account and try again.',
};
}
return undefined;
}
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
const { client, oauth2 } = clientAndYoutube();
client.setCredentials({ refresh_token });
const { credentials } = await client.refreshAccessToken();
const user = oauth2(client);
const expiryDate = new Date(credentials.expiry_date!);
const unixTimestamp =
Math.floor(expiryDate.getTime() / 1000) -
Math.floor(new Date().getTime() / 1000);
const { data } = await user.userinfo.get();
return {
accessToken: credentials.access_token!,
expiresIn: unixTimestamp!,
refreshToken: credentials.refresh_token!,
id: data.id!,
name: data.name!,
picture: data?.picture || '',
username: '',
};
}
async generateAuthUrl() {
const state = makeId(7);
const { client } = clientAndYoutube();
return {
url: client.generateAuthUrl({
access_type: 'offline',
prompt: 'consent',
state,
redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/youtube`,
scope: this.scopes.slice(0),
}),
codeVerifier: makeId(11),
state,
};
}
async authenticate(params: {
code: string;
codeVerifier: string;
refresh?: string;
}) {
const { client, oauth2 } = clientAndYoutube();
const { tokens } = await client.getToken(params.code);
client.setCredentials(tokens);
const { scopes } = await client.getTokenInfo(tokens.access_token!);
this.checkScopes(this.scopes, scopes);
const user = oauth2(client);
const { data } = await user.userinfo.get();
const expiryDate = new Date(tokens.expiry_date!);
const unixTimestamp =
Math.floor(expiryDate.getTime() / 1000) -
Math.floor(new Date().getTime() / 1000);
return {
accessToken: tokens.access_token!,
expiresIn: unixTimestamp,
refreshToken: tokens.refresh_token!,
id: data.id!,
name: data.name!,
picture: data?.picture || '',
username: '',
};
}
async post(
id: string,
accessToken: string,
postDetails: PostDetails[]
): Promise<PostResponse[]> {
const [firstPost, ...comments] = postDetails;
const { client, youtube } = clientAndYoutube();
client.setCredentials({ access_token: accessToken });
const youtubeClient = youtube(client);
const { settings }: { settings: YoutubeSettingsDto } = firstPost;
const response = await axios({
url: firstPost?.media?.[0]?.path,
method: 'GET',
responseType: 'stream',
});
const all: GaxiosResponse<Schema$Video> = await this.runInConcurrent(
async () =>
youtubeClient.videos.insert({
part: ['id', 'snippet', 'status'],
notifySubscribers: true,
requestBody: {
snippet: {
title: settings.title,
description: firstPost?.message,
...(settings?.tags?.length
? { tags: settings.tags.map((p) => p.label) }
: {}),
},
status: {
privacyStatus: settings.type,
},
},
media: {
body: response.data,
},
}),
true
);
if (settings?.thumbnail?.path) {
await this.runInConcurrent(async () =>
youtubeClient.thumbnails.set({
videoId: all?.data?.id!,
media: {
body: (
await axios({
url: settings?.thumbnail?.path,
method: 'GET',
responseType: 'stream',
})
).data,
},
})
);
}
return [
{
id: firstPost.id,
releaseURL: `https://www.youtube.com/watch?v=${all?.data?.id}`,
postId: all?.data?.id!,
status: 'success',
},
];
}
async analytics(
id: string,
accessToken: string,
date: number
): Promise<AnalyticsData[]> {
try {
const endDate = dayjs().format('YYYY-MM-DD');
const startDate = dayjs().subtract(date, 'day').format('YYYY-MM-DD');
const { client, youtubeAnalytics } = clientAndYoutube();
client.setCredentials({ access_token: accessToken });
const youtubeClient = youtubeAnalytics(client);
const { data } = await youtubeClient.reports.query({
ids: 'channel==MINE',
startDate,
endDate,
metrics:
'views,estimatedMinutesWatched,averageViewDuration,averageViewPercentage,subscribersGained,likes,subscribersLost',
dimensions: 'day',
sort: 'day',
});
const columns = data?.columnHeaders?.map((p) => p.name)!;
const mappedData = data?.rows?.map((p) => {
return columns.reduce((acc, curr, index) => {
acc[curr!] = p[index];
return acc;
}, {} as any);
});
const acc = [] as any[];
acc.push({
label: 'Estimated Minutes Watched',
data: mappedData?.map((p: any) => ({
total: p.estimatedMinutesWatched,
date: p.day,
})),
});
acc.push({
label: 'Average View Duration',
average: true,
data: mappedData?.map((p: any) => ({
total: p.averageViewDuration,
date: p.day,
})),
});
acc.push({
label: 'Average View Percentage',
average: true,
data: mappedData?.map((p: any) => ({
total: p.averageViewPercentage,
date: p.day,
})),
});
acc.push({
label: 'Subscribers Gained',
data: mappedData?.map((p: any) => ({
total: p.subscribersGained,
date: p.day,
})),
});
acc.push({
label: 'Subscribers Lost',
data: mappedData?.map((p: any) => ({
total: p.subscribersLost,
date: p.day,
})),
});
acc.push({
label: 'Likes',
data: mappedData?.map((p: any) => ({
total: p.likes,
date: p.day,
})),
});
return acc;
} catch (err) {
return [];
}
}
}