Skip to content

Commit

Permalink
feat: add YouTube video fetching and embedding support
Browse files Browse the repository at this point in the history
  • Loading branch information
wirapratamaz committed Oct 17, 2024
1 parent 677b54e commit 81ba6e7
Show file tree
Hide file tree
Showing 12 changed files with 265 additions and 5 deletions.
3 changes: 3 additions & 0 deletions .env-template
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,6 @@ MINIO_ENDPOINT=
MINIO_BUCKET_NAME=
MINIO_PORT=
MINIO_URL=

#YOUTUBE
YOUTUBE_API_KEY=
5 changes: 5 additions & 0 deletions src/__tests__/helpers/database.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,14 @@ import {
UserService,
UserSocialMediaService,
VoteService,
YouTubeProvider,
} from '../../services';
import {UserProfile, securityId} from '@loopback/security';
import {
CoinMarketCapDataSource,
RedditDataSource,
TwitterDataSource,
YouTubeDataSource,
} from '../../datasources';
import {CommentService} from '../../services/comment.service';

Expand Down Expand Up @@ -235,18 +237,21 @@ export async function givenRepositories(testdb: any) {
const dataSource = {
reddit: new RedditDataSource(),
twitter: new TwitterDataSource(),
youtube: new YouTubeDataSource(),
coinmarketcap: new CoinMarketCapDataSource(),
};

const redditService = await new RedditProvider(dataSource.reddit).value();
const twitterService = await new TwitterProvider(dataSource.twitter).value();
const youtubeService = await new YouTubeProvider(dataSource.youtube).value();
const coinmarketcapService = await new CoinMarketCapProvider(
dataSource.coinmarketcap,
).value();

const socialMediaService = new SocialMediaService(
twitterService,
redditService,
youtubeService,
);

const metricService = new MetricService(
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,6 @@ export const config = {
MINIO_PORT: process.env.MINIO_PORT ? parseInt(process.env.MINIO_PORT) : 9000,
MINIO_BUCKET_NAME: process.env.MINIO_BUCKET_NAME ?? '',
MINIO_URL: process.env.MINIO_URL ?? 'localhost:9000',

YOUTUBE_API_KEY: process.env.YOUTUBE_API_KEY ?? '',
};
1 change: 1 addition & 0 deletions src/datasources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './mongo.datasource';
export * from './reddit.datasource';
export * from './redis.datasource';
export * from './twitter.datasource';
export * from './youtube.datasource';
64 changes: 64 additions & 0 deletions src/datasources/youtube.datasource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {inject, lifeCycleObserver, LifeCycleObserver} from '@loopback/core';
import {juggler} from '@loopback/repository';
import {config} from '../config';

const youtubeConfig = {
name: 'youtube',
connector: 'rest',
baseUrl: 'https://www.googleapis.com/youtube/v3',
crud: false,
options: {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
strictSSL: true,
},
operations: [
{
template: {
method: 'GET',
url: '/videos',
query: {
part: '{part=snippet}',
id: '{id}',
key: '{key=' + config.YOUTUBE_API_KEY + '}',
},
},
functions: {
getVideos: ['part', 'id'],
},
},
{
template: {
method: 'GET',
url: '/search',
query: {
part: '{part=snippet}',
q: '{q}',
type: '{type=video}',
key: '{key=' + config.YOUTUBE_API_KEY + '}',
},
},
functions: {
search: ['part', 'q', 'type'],
},
},
],
};

@lifeCycleObserver('datasource')
export class YouTubeDataSource
extends juggler.DataSource
implements LifeCycleObserver
{
static dataSourceName = 'youtube';
static readonly defaultConfig = youtubeConfig;

constructor(
@inject('datasources.config.youtube', {optional: true})
dsConfig: object = youtubeConfig,
) {
super(dsConfig);
}
}
1 change: 1 addition & 0 deletions src/enums/platform-type.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export enum PlatformType {
TWITTER = 'twitter',
REDDIT = 'reddit',
FACEBOOK = 'facebook',
YOUTUBE = 'youtube',
}
4 changes: 4 additions & 0 deletions src/services/post.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,10 @@ export class PostService {
);
break;

case PlatformType.YOUTUBE:
rawPost = await this.socialMediaService.fetchYouTubeVideo(originPostId);
break;

default:
throw new HttpErrors.BadRequest('Cannot find the platform!');
}
Expand Down
1 change: 1 addition & 0 deletions src/services/social-media/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './facebook.service';
export * from './reddit.service';
export * from './twitter.service';
export * from './youtube.service';
export * from './social-media.service';
133 changes: 131 additions & 2 deletions src/services/social-media/social-media.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {BindingScope, inject, injectable} from '@loopback/core';
import {AnyObject} from '@loopback/repository';
import {HttpErrors} from '@loopback/rest';
import {PlatformType} from '../../enums';
import {PlatformType, VisibilityType} from '../../enums';
import {Asset, Sizes} from '../../interfaces';
import {EmbeddedURL, ExtendedPost, Media, People} from '../../models';
import {Reddit, Twitter} from '..';
import {Reddit, Twitter, Youtube} from '..';
import {formatRawText} from '../../utils/formatter';
import {UrlUtils} from '../../utils/url-utils';

Expand All @@ -17,6 +17,8 @@ export class SocialMediaService {
private twitterService: Twitter,
@inject('services.Reddit')
private redditService: Reddit,
@inject('services.YouTube')
private youTubeService: Youtube,
) {}

async fetchTweet(textId: string): Promise<ExtendedPost> {
Expand Down Expand Up @@ -420,7 +422,104 @@ export class SocialMediaService {
},
} as ExtendedPost;
}
async fetchYouTubeVideo(videoId: string): Promise<ExtendedPost> {
let response: any = null;

try {
response = await this.youTubeService.getVideos(
'snippet,contentDetails,statistics',
videoId,
);
} catch (error) {
console.error('Error fetching YouTube video:', error);
throw new HttpErrors.BadRequest(
'Invalid YouTube video ID or Video not found',
);
}

if (!response?.items?.length) {
console.error('No video found for ID:', videoId);
throw new HttpErrors.BadRequest(
'Invalid YouTube video ID or Video not found',
);
}

const video = response.items[0];
const {id: idStr, snippet} = video;

const {
title,
description,
publishedAt,
channelId,
channelTitle,
thumbnails,
tags,
} = snippet;

const asset: Asset = {
images: [
{
original: thumbnails.high.url,
thumbnail: thumbnails.default.url,
small: thumbnails.medium.url,
medium: thumbnails.high.url,
large: thumbnails.maxres
? thumbnails.maxres.url
: thumbnails.high.url,
},
],
videos: [`https://www.youtube.com/watch?v=${videoId}`],
exclusiveContents: [],
};

let embeddedURL: EmbeddedURL | undefined = undefined;

try {
embeddedURL = new EmbeddedURL({
title: title,
description: description,
siteName: 'YouTube',
url: `https://www.youtube.com/watch?v=${videoId}`,
image: new Media({
url: thumbnails.high.url,
}),
});
} catch (error) {
console.error('Error creating EmbeddedURL:', error);
}

const youtubeTags = tags
? tags.map((tag: string) => tag.toLowerCase())
: [];

return {
metric: {
upvotes: 0,
downvotes: 0,
},
isNSFW: false,
visibility: VisibilityType.PUBLIC,
platform: PlatformType.YOUTUBE,
originPostId: idStr,
title: title,
text: description.trim(),
rawText: formatRawText(description),
tags: youtubeTags.filter((tag: string) => Boolean(tag)),
originCreatedAt: new Date(publishedAt).toISOString(),
asset: asset,
embeddedURL: embeddedURL,
url: `https://www.youtube.com/watch?v=${videoId}`,
platformUser: new People({
name: channelTitle,
username: channelId,
originUserId: channelId,
profilePictureURL: '',
platform: PlatformType.YOUTUBE,
}),
// Include only properties defined in the 'Post' model
} as unknown as ExtendedPost;
}
public async verifyToTwitter(
username: string,
address: string,
Expand Down Expand Up @@ -494,4 +593,34 @@ export class SocialMediaService {
throw new HttpErrors.NotFound('Cannot find the specified post');
}
}

public async verifyToYouTube(videoId: string): Promise<People> {
let response = null;

try {
response = await this.youTubeService.getVideos('snippet', videoId);
} catch (error) {
throw new HttpErrors.NotFound(
'Invalid YouTube video ID or Video not found',
);
}

if (!response?.items?.length) {
throw new HttpErrors.NotFound('Invalid YouTube video ID');
}

const video = response.items[0];
const snippet = video.snippet;
const channelTitle = snippet.channelTitle || 'Unknown Channel';
const channelId = snippet.channelId || 'Unknown ID';
const thumbnailUrl = snippet.thumbnails?.default?.url || '';

return new People({
name: channelTitle,
originUserId: channelId,
platform: PlatformType.YOUTUBE,
username: channelTitle.replace(/\s+/g, '').toLowerCase(),
profilePictureURL: thumbnailUrl,
});
}
}
24 changes: 24 additions & 0 deletions src/services/social-media/youtube.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {inject, Provider} from '@loopback/core';
import {getService} from '@loopback/service-proxy';
import {YouTubeDataSource} from '../../datasources';

/* eslint-disable @typescript-eslint/no-explicit-any */
export interface Youtube {
getVideos(part: string, id: string): Promise<any>;
search(part: string, q: string, type: string): Promise<any>;
// this is where you define the Node.js methods that will be
// mapped to REST/SOAP/gRPC operations as stated in the datasource
// json file.
}

export class YouTubeProvider implements Provider<Youtube> {
constructor(
// youtube must match the name property in the datasource json file
@inject('datasources.youtube')
protected dataSource: YouTubeDataSource = new YouTubeDataSource(),
) {}

value(): Promise<Youtube> {
return getService(this.dataSource);
}
}
1 change: 1 addition & 0 deletions src/services/user-experience.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,7 @@ export class UserExperienceService {
PlatformType.MYRIAD,
PlatformType.REDDIT,
PlatformType.TWITTER,
PlatformType.YOUTUBE,
];

if (platforms.includes(e.platform)) return true;
Expand Down
31 changes: 28 additions & 3 deletions src/utils/url-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,34 @@ export class UrlUtils {
}

getOriginPostId(): string {
return this.url.pathname
.replace(new RegExp(/\/user\/|\/u\/|\/r\//), '/')
.split('/')[3];
const platform = this.getPlatform();
const pathname = this.url.pathname;
let postId = '';

switch (platform) {
case PlatformType.YOUTUBE:
if (pathname === '/watch') {
// Handle standard YouTube URLs: https://www.youtube.com/watch?v=VIDEO_ID
postId = this.url.searchParams.get('v') ?? '';
} else if (this.url.hostname === 'youtu.be') {
// Handle shortened YouTube URLs: https://youtu.be/VIDEO_ID
postId = pathname.substring(1);
}
break;

case PlatformType.REDDIT:
case PlatformType.TWITTER:
// Handle Reddit and Twitter URLs
postId =
pathname
.replace(new RegExp(/\/user\/|\/u\/|\/r\//), '/')
.split('/')[3] || '';
break;
default:
postId = '';
}

return postId;
}

static async getOpenGraph(url: string): Promise<EmbeddedURL | null> {
Expand Down

0 comments on commit 81ba6e7

Please sign in to comment.