Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Twitch fetching and embedding support #980

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
8 changes: 8 additions & 0 deletions .env-template
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,11 @@ MINIO_ENDPOINT=
MINIO_BUCKET_NAME=
MINIO_PORT=
MINIO_URL=

#YOUTUBE
YOUTUBE_API_KEY=

# TWITCH
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
TWITCH_ACCESS_TOKEN=
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,11 +62,13 @@ import {
UserService,
UserSocialMediaService,
VoteService,
TwitchProvider,
} from '../../services';
import {UserProfile, securityId} from '@loopback/security';
import {
CoinMarketCapDataSource,
RedditDataSource,
TwitchDataSource,
TwitterDataSource,
} 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(),
twitch: new TwitchDataSource(),
coinmarketcap: new CoinMarketCapDataSource(),
};

const redditService = await new RedditProvider(dataSource.reddit).value();
const twitterService = await new TwitterProvider(dataSource.twitter).value();
const twitchService = await new TwitchProvider(dataSource.twitch).value();
const coinmarketcapService = await new CoinMarketCapProvider(
dataSource.coinmarketcap,
).value();

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

const metricService = new MetricService(
Expand Down
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,8 @@ 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',

TWITCH_CLIENT_ID: process.env.TWITCH_CLIENT_ID ?? '',
TWITCH_SECRET: process.env.TWITCH_SECRET ?? '',
TWITCH_ACCESS_TOKEN: process.env.TWITCH_ACCESS_TOKEN ?? '',
};
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 './twitch.datasource';
65 changes: 65 additions & 0 deletions src/datasources/twitch.datasource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { inject, lifeCycleObserver, LifeCycleObserver } from '@loopback/core';
import { juggler } from '@loopback/repository';
import {config} from '../config';

const twitchConfig = {
name: 'twitch',
connector: 'rest',
baseUrl: 'https://api.twitch.tv/helix',
crud: false,
options: {
headers: {
'Client-ID': config.TWITCH_CLIENT_ID,
Authorization: `Bearer ${config.TWITCH_ACCESS_TOKEN}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
strictSSL: true,
},
operations: [
{
template: {
method: 'GET',
url: '/videos',
query: {
id: '{id}',
},
},
functions: {
getVideoById: ['id'],
},
},
{
template: {
method: 'GET',
url: '/clips',
query: {
id: '{id}',
},
},
functions: {
getClipById: ['id'],
},
},
],
};

// Observe application's life cycle to disconnect the datasource when
// application is stopped. This allows the application to be shut down
// gracefully. The `stop()` method is inherited from `juggler.DataSource`.
// Learn more at https://loopback.io/doc/en/lb4/Life-cycle.html
@lifeCycleObserver('datasource')
export class TwitchDataSource
extends juggler.DataSource
implements LifeCycleObserver
{
static dataSourceName = 'twitch';
static readonly defaultConfig = twitchConfig;

constructor(
@inject('datasources.config.twitch', {optional: true})
dsConfig: object = twitchConfig,
) {
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',
TWITCH = 'twitch',
}
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.TWITCH:
rawPost = await this.socialMediaService.fetchTwitchClip(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 './twitch.service';
export * from './social-media.service';
54 changes: 52 additions & 2 deletions src/services/social-media/social-media.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,21 @@ import {HttpErrors} from '@loopback/rest';
import {PlatformType} from '../../enums';
import {Asset, Sizes} from '../../interfaces';
import {EmbeddedURL, ExtendedPost, Media, People} from '../../models';
import {Reddit, Twitter} from '..';
import {Reddit, Twitter, Twitch} from '..';
import {formatRawText} from '../../utils/formatter';
import {UrlUtils} from '../../utils/url-utils';

const {validateURL, getOpenGraph} = UrlUtils;

@injectable({scope: BindingScope.TRANSIENT})
@injectable({ scope: BindingScope.TRANSIENT })
export class SocialMediaService {
constructor(
@inject('services.Twitter')
private twitterService: Twitter,
@inject('services.Reddit')
private redditService: Reddit,
@inject('services.Twitch')
private twitchService: Twitch,
) {}

async fetchTweet(textId: string): Promise<ExtendedPost> {
Expand Down Expand Up @@ -421,6 +423,54 @@ export class SocialMediaService {
} as ExtendedPost;
}

async fetchTwitchClip(clipId: string): Promise<ExtendedPost> {
const response = await this.twitchService.getClipById(clipId);

if (!response.data || response.data.length === 0) {
throw new HttpErrors.NotFound('Invalid Twitch clip ID or clip not found');
}

const clip = response.data;

// Fetch broadcaster's profile picture
const userResponse = await this.twitchService.getUserById(clip.broadcasterId);
const userData = userResponse.data;
const profilePictureURL = userData.profileImageUrl || '';

const asset: Asset = {
images: [
{
original: clip.thumbnailUrl,
thumbnail: clip.thumbnailUrl,
small: clip.thumbnailUrl,
medium: clip.thumbnailUrl,
large: clip.thumbnailUrl,
},
],
videos: [clip.embedUrl],
exclusiveContents: [],
};

return {
platform: PlatformType.TWITCH,
originPostId: clip.id,
text: clip.title || '',
rawText: formatRawText(clip.title || ''),
tags: [],
originCreatedAt: new Date(clip.createdAt).toISOString(),
asset: asset,
embeddedURL: undefined,
url: clip.url,
platformUser: new People({
name: clip.broadcasterName,
username: clip.broadcasterName,
originUserId: clip.broadcasterId,
profilePictureURL: profilePictureURL,
platform: PlatformType.TWITCH,
}),
} as unknown as ExtendedPost;
}

public async verifyToTwitter(
username: string,
address: string,
Expand Down
60 changes: 60 additions & 0 deletions src/services/social-media/twitch.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { inject, Provider } from '@loopback/core';
import { getService } from '@loopback/service-proxy';
import { TwitchDataSource } from '../../datasources';

interface TwitchClipData {
broadcasterName: string;
createdAt: string | number | Date;
id: string;
embedUrl: string;
thumbnailUrl: string;
length: number;
title: string;
url: string;
broadcasterId: string;
}

interface TwitchClip {
data: TwitchClipData;
id: string;
}

interface TwitchVideoData {
title: string;
url: string;
broadcasterId: string;
}

interface TwitchVideo {
data: TwitchVideoData;
id: string;
}

interface TwitchUserData {
profileImageUrl: string;
login: string;
displayName: string;
id: string;
}

interface TwitchUser {
data: TwitchUserData;
id: string;
}

export interface Twitch {
getClipById(clipId: string): Promise<TwitchClip>;
getVideoById(id: string): Promise<TwitchVideo>;
getUserById(id: string): Promise<TwitchUser>;
}

export class TwitchProvider implements Provider<Twitch> {
constructor(
@inject('datasources.twitch')
protected dataSource: TwitchDataSource = new TwitchDataSource(),
) {}

value(): Promise<Twitch> {
return getService(this.dataSource);
}
}
28 changes: 25 additions & 3 deletions src/utils/url-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,31 @@ 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.REDDIT:
case PlatformType.TWITTER:
// Handle Reddit and Twitter URLs
postId =
pathname
.replace(new RegExp(/\/user\/|\/u\/|\/r\//), '/')
.split('/')[3] || '';
break;

case PlatformType.TWITCH: {
// Example Twitch URL: https://www.twitch.tv/videos/123456789
const pathSegments = this.url.pathname.split('/');
const idIndex = pathSegments.findIndex(segment => segment === 'videos') + 1;
return pathSegments[idIndex] || '';
}
default:
postId = '';
}

return postId;
}

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