From 05e53b0fbcf4fa92bc9a248fc1651de287f45203 Mon Sep 17 00:00:00 2001 From: Wira Pratama Putra <89260515+wirapratamaz@users.noreply.github.com> Date: Fri, 8 Nov 2024 20:41:39 +0800 Subject: [PATCH] feat: add YouTube video fetching and embedding support (#976) --- .env-template | 3 + src/__tests__/helpers/database.helper.ts | 5 + src/config.ts | 2 + src/datasources/index.ts | 1 + src/datasources/youtube.datasource.ts | 64 +++++++++ src/enums/platform-type.enum.ts | 1 + src/services/post.service.ts | 4 + src/services/social-media/index.ts | 1 + .../social-media/social-media.service.ts | 133 +++++++++++++++++- src/services/social-media/youtube.service.ts | 24 ++++ src/services/user-experience.service.ts | 1 + src/utils/url-utils.ts | 31 +++- 12 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 src/datasources/youtube.datasource.ts create mode 100644 src/services/social-media/youtube.service.ts diff --git a/.env-template b/.env-template index 1907b2f18..b115ef234 100644 --- a/.env-template +++ b/.env-template @@ -52,3 +52,6 @@ MINIO_ENDPOINT= MINIO_BUCKET_NAME= MINIO_PORT= MINIO_URL= + +#YOUTUBE +YOUTUBE_API_KEY= \ No newline at end of file diff --git a/src/__tests__/helpers/database.helper.ts b/src/__tests__/helpers/database.helper.ts index 761553646..d322c24f5 100644 --- a/src/__tests__/helpers/database.helper.ts +++ b/src/__tests__/helpers/database.helper.ts @@ -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'; @@ -235,11 +237,13 @@ 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(); @@ -247,6 +251,7 @@ export async function givenRepositories(testdb: any) { const socialMediaService = new SocialMediaService( twitterService, redditService, + youtubeService, ); const metricService = new MetricService( diff --git a/src/config.ts b/src/config.ts index 2de67b75f..8e3eeaf93 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 ?? '', }; diff --git a/src/datasources/index.ts b/src/datasources/index.ts index 865dc53db..ce8b477c3 100644 --- a/src/datasources/index.ts +++ b/src/datasources/index.ts @@ -4,3 +4,4 @@ export * from './mongo.datasource'; export * from './reddit.datasource'; export * from './redis.datasource'; export * from './twitter.datasource'; +export * from './youtube.datasource'; diff --git a/src/datasources/youtube.datasource.ts b/src/datasources/youtube.datasource.ts new file mode 100644 index 000000000..bd38735fe --- /dev/null +++ b/src/datasources/youtube.datasource.ts @@ -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); + } +} diff --git a/src/enums/platform-type.enum.ts b/src/enums/platform-type.enum.ts index 8463f8809..34751859a 100644 --- a/src/enums/platform-type.enum.ts +++ b/src/enums/platform-type.enum.ts @@ -3,4 +3,5 @@ export enum PlatformType { TWITTER = 'twitter', REDDIT = 'reddit', FACEBOOK = 'facebook', + YOUTUBE = 'youtube', } diff --git a/src/services/post.service.ts b/src/services/post.service.ts index bd461e9a2..e1170775e 100644 --- a/src/services/post.service.ts +++ b/src/services/post.service.ts @@ -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!'); } diff --git a/src/services/social-media/index.ts b/src/services/social-media/index.ts index c88877af7..4e28c72a8 100644 --- a/src/services/social-media/index.ts +++ b/src/services/social-media/index.ts @@ -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'; diff --git a/src/services/social-media/social-media.service.ts b/src/services/social-media/social-media.service.ts index 9fde38a9b..cbfa4c6c7 100644 --- a/src/services/social-media/social-media.service.ts +++ b/src/services/social-media/social-media.service.ts @@ -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'; @@ -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 { @@ -420,7 +422,104 @@ export class SocialMediaService { }, } as ExtendedPost; } + async fetchYouTubeVideo(videoId: string): Promise { + 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, @@ -494,4 +593,34 @@ export class SocialMediaService { throw new HttpErrors.NotFound('Cannot find the specified post'); } } + + public async verifyToYouTube(videoId: string): Promise { + 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, + }); + } } diff --git a/src/services/social-media/youtube.service.ts b/src/services/social-media/youtube.service.ts new file mode 100644 index 000000000..cd3951a58 --- /dev/null +++ b/src/services/social-media/youtube.service.ts @@ -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; + search(part: string, q: string, type: string): Promise; + // 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 { + constructor( + // youtube must match the name property in the datasource json file + @inject('datasources.youtube') + protected dataSource: YouTubeDataSource = new YouTubeDataSource(), + ) {} + + value(): Promise { + return getService(this.dataSource); + } +} diff --git a/src/services/user-experience.service.ts b/src/services/user-experience.service.ts index 51d0b1eca..f4b0c79b0 100644 --- a/src/services/user-experience.service.ts +++ b/src/services/user-experience.service.ts @@ -698,6 +698,7 @@ export class UserExperienceService { PlatformType.MYRIAD, PlatformType.REDDIT, PlatformType.TWITTER, + PlatformType.YOUTUBE, ]; if (platforms.includes(e.platform)) return true; diff --git a/src/utils/url-utils.ts b/src/utils/url-utils.ts index f6c7bb2a0..4d6a96307 100644 --- a/src/utils/url-utils.ts +++ b/src/utils/url-utils.ts @@ -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 {