diff --git a/src/constants/fake.constants.ts b/src/constants/fake.constants.ts index 4a0a509..71677e1 100644 --- a/src/constants/fake.constants.ts +++ b/src/constants/fake.constants.ts @@ -4,7 +4,7 @@ import { ITwitchLiveStream, ITwitchSearchedChannel, ITwitchUser, - ITwitchUserFollowsFromTo, + ITwitchUserChannelFollow, ITwitchVideo } from '../services/twitch.service' @@ -44,12 +44,10 @@ export const fakeTwitchLiveStream: ITwitchLiveStream = { viewer_count: 78365 } -export const fakeTwitchUserFollowsFromTo: ITwitchUserFollowsFromTo = { - from_id: '171003792', - from_login: 'iiisutha067iii', - from_name: 'IIIsutha067III', - to_id: '23161357', - to_name: 'LIRIK', +export const fakeTwitchUserFollowsFromTo: ITwitchUserChannelFollow = { + broadcaster_id: '23161357', + broadcaster_login: 'LIRIK', + broadcaster_name: 'LIRIK', followed_at: '2017-08-22T22:55:24Z' } diff --git a/src/hooks/useFollowedStreams.spec.ts b/src/hooks/useFollowedStreams.spec.ts index dd2703e..b76718c 100644 --- a/src/hooks/useFollowedStreams.spec.ts +++ b/src/hooks/useFollowedStreams.spec.ts @@ -56,7 +56,7 @@ describe('Hook: useLiveFollowedStreams', () => { expect(getUserFollowsSpy).toBeCalledTimes(1) expect(getUserFollowsSpy).toBeCalledWith(fakeTwitchUser.id) expect(getUsersSpy).toBeCalledTimes(1) - expect(getUsersSpy).toBeCalledWith([fakeTwitchUserFollowsFromTo.to_id]) + expect(getUsersSpy).toBeCalledWith([fakeTwitchUserFollowsFromTo.broadcaster_id]) }) it('should return an error', async () => { diff --git a/src/hooks/useFollowedStreams.ts b/src/hooks/useFollowedStreams.ts index 02f7449..79966f6 100644 --- a/src/hooks/useFollowedStreams.ts +++ b/src/hooks/useFollowedStreams.ts @@ -1,50 +1,49 @@ -import { IApiServiceError } from '../services/api.service' -import { useEffect, useState } from 'react' -import twitchService, { ITwitchUserFollowsFromTo } from '../services/twitch.service' -import type { ITwitchError, ITwitchLiveStream, ITwitchUser } from '../services/twitch.service' - - -/** - * A React hook to get information about streams belonging to channels that the authenticated user follows. - * - * @param isOfflineHidden - Specifies whether to show channels that are currently offline. - * @param minViewCount - The minimum number of views to display a channel. - * @returns an array of three elements: error, isLoading and an array of live stream. - */ -export default ( - isOfflineHidden: boolean = false, - minViewCount: number = 1e4 -): [ITwitchError | IApiServiceError | undefined, boolean, Array, Array] => { - const [error, setError] = useState() - const [isLoading, setIsLoading] = useState(true) - const [liveStreams, setLiveStreams] = useState>([]) - const [offlineStreams, setOfflineStreams] = useState>([]) - - useEffect(() => { - const getStreams = async () => { - const authUser: ITwitchUser = await twitchService.getAuthUser() - const liveStreams: Array = await twitchService.getLiveFollowedStreams(authUser.id) - - setLiveStreams(liveStreams) - - if (isOfflineHidden) return - - const followedUsers: Array = await twitchService.getUserFollows(authUser.id) - const offlineFollowedUsers: Array = followedUsers.filter( - ({ to_id }) => !liveStreams.some(({ user_id }) => user_id === to_id) - ) - const offlineStreams: Array = await twitchService.getUsers( - offlineFollowedUsers.map(({ to_id }) => to_id) - ) - const filteredOfflineStreams: Array = offlineStreams.filter( - ({ view_count }) => Number(view_count) > minViewCount - ) - - setOfflineStreams(filteredOfflineStreams) - } - - getStreams().catch(setError).finally(() => setIsLoading(false)) - }, []) - - return [error, isLoading, liveStreams, offlineStreams] -} +import { IApiServiceError } from '../services/api.service' +import { useEffect, useState } from 'react' +import twitchService, { ITwitchUserChannelFollow } from '../services/twitch.service' +import type { ITwitchError, ITwitchLiveStream, ITwitchUser } from '../services/twitch.service' + + +/** + * A React hook to get information about streams belonging to channels that the authenticated user follows. + * + * @param isOfflineHidden - Specifies whether to show channels that are currently offline. + * @param minViewCount - The minimum number of views to display a channel. + * @returns an array of three elements: error, isLoading and an array of live stream. + */ +export default ( + isOfflineHidden: boolean = false +): [ITwitchError | IApiServiceError | undefined, boolean, Array, Array] => { + const [error, setError] = useState() + const [isLoading, setIsLoading] = useState(true) + const [liveStreams, setLiveStreams] = useState>([]) + const [offlineStreams, setOfflineStreams] = useState>([]) + + useEffect(() => { + const getStreams = async () => { + const authUser: ITwitchUser = await twitchService.getAuthUser() + const liveStreams: Array = await twitchService.getLiveFollowedStreams(authUser.id) + + setLiveStreams(liveStreams) + + if (isOfflineHidden) return + + const followedUsers: Array = await twitchService.getUserFollows(authUser.id) + const offlineFollowedUsers: Array = followedUsers.filter( + ({ broadcaster_id }) => !liveStreams.some(({ user_id }) => user_id === broadcaster_id) + ) + + const offlineStreams: Array = await twitchService.getUsers( + offlineFollowedUsers.map(({ broadcaster_id }) => broadcaster_id) + ) + + setOfflineStreams(offlineStreams) + } + + getStreams() + .catch(setError) + .finally(() => setIsLoading(false)) + }, []) + + return [error, isLoading, liveStreams, offlineStreams] +} diff --git a/src/services/twitch.service.spec.ts b/src/services/twitch.service.spec.ts index 581c5cb..7365874 100644 --- a/src/services/twitch.service.spec.ts +++ b/src/services/twitch.service.spec.ts @@ -122,7 +122,7 @@ describe('Twitch Service', () => { describe('getUserFollows', () => { it('should return a list of users', async () => { nock(TwitchResources.host) - .get(`${TwitchResources.follows}?from_id=${fakeTwitchUser.id}`) + .get(`${TwitchResources.follows}?user_id=${fakeTwitchUser.id}`) .reply(200, { data: [fakeTwitchUserFollowsFromTo, fakeTwitchUserFollowsFromTo] }) await expect(twitchService.getUserFollows(fakeTwitchUser.id as string)).resolves.toStrictEqual([ fakeTwitchUserFollowsFromTo, @@ -131,10 +131,10 @@ describe('Twitch Service', () => { }) it('should return an error if the request fails', async () => { - nock(TwitchResources.host).get(`${TwitchResources.follows}?from_id=${fakeTwitchUser.id}`).reply(401, fakeTwitchError) + nock(TwitchResources.host).get(`${TwitchResources.follows}?user_id=${fakeTwitchUser.id}`).reply(401, fakeTwitchError) await expect(twitchService.getUserFollows(fakeTwitchUser.id as string)).rejects.toStrictEqual(fakeTwitchError) - nock(TwitchResources.host).get(`${TwitchResources.follows}?from_id=${fakeTwitchUser.id}`).reply(200, '') + nock(TwitchResources.host).get(`${TwitchResources.follows}?user_id=${fakeTwitchUser.id}`).reply(200, '') await expect(twitchService.getUserFollows(fakeTwitchUser.id as string)).rejects.toStrictEqual( API_SERVICE_PARSE_ERROR ) diff --git a/src/services/twitch.service.ts b/src/services/twitch.service.ts index 734e5f2..1c002a4 100644 --- a/src/services/twitch.service.ts +++ b/src/services/twitch.service.ts @@ -1,318 +1,315 @@ -import querystring from 'node:querystring' -import ApiService from './api.service' -import type { OutgoingHttpHeaders } from 'node:http' -import { Singleton } from '../decorators/singleton.decorator' -import { authToken, clientId } from '../core/preferences' - - -export interface ITwitchService { - getAuthUser(): Promise - getClips(broadcasterId: string): Promise> - getLiveFollowedStreams(userId: string | number): Promise> - getUserFollows(userId: string | number): Promise> - getUsers(userIDsOrLogins: Array): Promise> - getUserVideos(userId: string): Promise> - searchChannels(query: string): Promise> -} - -export interface ITwitchResponse { - data: Array -} - -export interface ITwitchError { - error: string - status: number - message: string -} - -export interface ITwitchUser { - id: string - login: string - display_name: string - type: 'staff' | 'admin' | 'global_mod' | '' - broadcaster_type: 'partner' | 'affiliate' | '' - description: string - profile_image_url: string - offline_image_url: string - view_count: number - created_at: string - email?: string -} - -export interface ITwitchLiveStream { - id: string - user_id: string - user_login: string - user_name: string - game_id: string - game_name: string - type: 'live' | '' - title: string - viewer_count: number - started_at: string - language: string - thumbnail_url: string - tag_ids: Array - is_mature: boolean -} - -export interface ITwitchUserFollowsFromTo { - from_id: string - from_login: string - from_name: string - to_id: string - to_name: string - followed_at: string -} - -export interface ITwitchSearchedChannel { - broadcaster_language: string - broadcaster_login: string - display_name: string - game_id: string - game_name: string - id: string - is_live: boolean - started_at: string - tags_ids: Array - thumbnail_url: string - title: string -} - -export interface ITwitchVideo { - created_at: string - description: string - duration: string - id: string - language: string - muted_segments: Array<{ duration: number, offset: number }> - published_at: string - stream_id: string | null - thumbnail_url: string - title: string - type: string - url: string - user_id: string - user_login: string - user_name: string - view_count: number - viewable: string -} - -export interface ITwitchClip { - broadcaster_id: string - broadcaster_name: string - created_at: string - creator_id: string - creator_name: string - duration: number - embed_url: string - game_id: string - id: string - language: string - thumbnail_url: string - title: string - url: string - video_id: string - view_count: number - vod_offset: number -} - -export const enum TwitchResources { - host = 'https://api.twitch.tv', - clips = '/helix/clips', - followed = '/helix/streams/followed', - follows = '/helix/users/follows', - searchChannels = '/helix/search/channels', - users = '/helix/users', - videos = '/helix/videos' -} - -export interface ITwitchGetUserVideosQueryParams extends querystring.ParsedUrlQueryInput { - after?: string - before?: string - first?: string - language?: string - period?: string - sort?: 'time' | 'trending' | 'views' - type?: TwitchVideoType -} - -export interface ITwitchGetClipsQueryParams extends querystring.ParsedUrlQueryInput { - after?: string - before?: string - ended_at?: string - first?: number - started_at?: string -} - -export enum TwitchVideoType { - all = 'all', - archive = 'archive', - highlight = 'highlight', - upload = 'upload' -} - -export const enum TwitchMediaType { - clip = 'clip', - video = 'video' -} - -/** - * Service for working with Twitch.tv API - * - * @remarks - * Twitch API Docs- {@link https://dev.twitch.tv/docs} - */ -@Singleton -class TwitchService extends ApiService implements ITwitchService { - /** A user authenticated with a Bearer token. */ - private _authUser: ITwitchUser | undefined - - constructor(host: string, headers: OutgoingHttpHeaders) { - super(host, headers) - } - - /** - * Gets information about a user authenticated with a Bearer token. - * - * @remarks - * Twitch API Reference Get Users - {@link https://dev.twitch.tv/docs/api/reference#get-users} - * - * @returns user information. - */ - public async getAuthUser(): Promise { - if (this._authUser) return this._authUser - - const { data } = await this.get, ITwitchError>(TwitchResources.users) - this._authUser = data[0] - - return this._authUser - } - - /** - * Gets clip information by broadcaster ID. - * - * @remarks - * Twitch API Reference Get Clips - {@link https://dev.twitch.tv/docs/api/reference#get-clips} - * - * @param broadcasterId - The ID of a broadcaster whose clips are being requested. - * @param queryParams - Optional query params {@link ITwitchGetClipsQueryParams}. - * @returns a list of clips. - */ - public async getClips(broadcasterId: string, queryParams?: ITwitchGetClipsQueryParams): Promise> { - if (!broadcasterId) return [] - - const { data } = await this.get>( - `${TwitchResources.clips}?broadcaster_id=${broadcasterId}${ - queryParams ? '&' + querystring.stringify(queryParams) : '' - }` - ) - - return data - } - - /** - * Gets information about live streams belonging to channels that the authenticated user follows. - * - * @remarks - * Twitch API Reference Get Followed Streams - {@link https://dev.twitch.tv/docs/api/reference#get-followed-streams} - * - * @param userId - the User ID in the bearer token. - * @returns information about live streams. - */ - public async getLiveFollowedStreams(userId: string | number): Promise> { - const { data } = await this.get, ITwitchError>( - `${TwitchResources.followed}?user_id=${userId}` - ) - return data - } - - /** - * Gets information about users who are being followed by a user. - * - * @remarks - * Twitch API Reference Get Users Follows - {@link https://dev.twitch.tv/docs/api/reference#get-users-follows} - * - * @param userId - the user ID. - * @returns information about users who are being followed by the from_id user. - */ - public async getUserFollows(userId: string | number): Promise> { - const { data } = await this.get, ITwitchError>( - `${TwitchResources.follows}?from_id=${userId}` - ) - return data - } - - /** - * Gets information about one or more specified Twitch users. - * - * @remarks - * Twitch API Reference Get Users - {@link https://dev.twitch.tv/docs/api/reference#get-users} - * - * @param userIDsOrLogins - the list of user IDs or logins. - * @returns returns a list with information about Twitch users. - */ - public async getUsers(userIDsOrLogins: Array): Promise> { - if (userIDsOrLogins.length === 0) return [] - - const queryParams: string = userIDsOrLogins - .reduce((prev: string, cur: string) => `${prev}${isNaN(Number(cur)) ? 'login=' : 'id='}${cur}&`, '') - .slice(0, -1) - - const { data } = await this.get, ITwitchError>( - `${TwitchResources.users}?${queryParams}` - ) - return data - } - - /** - * Gets video information by user ID. - * - * @remarks - * Twitch API Reference Get Videos - {@link https://dev.twitch.tv/docs/api/reference#get-videos} - * - * @param userId - The user ID. - * @param queryParams - Optional query params. - * @returns a list of user videos. - */ - public async getUserVideos( - userId: string, - queryParams?: ITwitchGetUserVideosQueryParams - ): Promise> { - if (!userId) return [] - - const { data } = await this.get>( - `${TwitchResources.videos}?user_id=${encodeURI(userId)}${ - queryParams ? '&' + querystring.stringify(queryParams) : '' - }` - ) - - return data - } - - /** - * Searches for channels (users who have streamed within the past 6 months) - * that match a query via channel name or description either entirely or partially. - * - * @remarks - * Twitch API Reference Search Channels - {@link https://dev.twitch.tv/docs/api/reference#search-channels} - * - * @param query - The search query. - * @returns a list of channels (users who have streamed within the past 6 months). - */ - public async searchChannels(query: string): Promise> { - if (query === '') return [] - - const { data } = await this.get>( - `${TwitchResources.searchChannels}?query=${encodeURI(query)}` - ) - - return data - } -} - -export default new TwitchService(TwitchResources.host, { - 'Authorization': `Bearer ${ authToken }`, - 'Client-Id': clientId -}) +import querystring from 'node:querystring' +import ApiService from './api.service' +import type { OutgoingHttpHeaders } from 'node:http' +import { Singleton } from '../decorators/singleton.decorator' +import { authToken, clientId } from '../core/preferences' + +export interface ITwitchService { + getAuthUser(): Promise + getClips(broadcasterId: string): Promise> + getLiveFollowedStreams(userId: string | number): Promise> + getUserFollows(userId: string | number): Promise> + getUsers(userIDsOrLogins: Array): Promise> + getUserVideos(userId: string): Promise> + searchChannels(query: string): Promise> +} + +export interface ITwitchResponse { + data: Array +} + +export interface ITwitchError { + error: string + status: number + message: string +} + +export interface ITwitchUser { + id: string + login: string + display_name: string + type: 'staff' | 'admin' | 'global_mod' | '' + broadcaster_type: 'partner' | 'affiliate' | '' + description: string + profile_image_url: string + offline_image_url: string + view_count: number + created_at: string + email?: string +} + +export interface ITwitchLiveStream { + id: string + user_id: string + user_login: string + user_name: string + game_id: string + game_name: string + type: 'live' | '' + title: string + viewer_count: number + started_at: string + language: string + thumbnail_url: string + tag_ids: Array + is_mature: boolean +} + +export interface ITwitchUserChannelFollow { + broadcaster_id: string + broadcaster_login: string + broadcaster_name: string + followed_at: string +} + +export interface ITwitchSearchedChannel { + broadcaster_language: string + broadcaster_login: string + display_name: string + game_id: string + game_name: string + id: string + is_live: boolean + started_at: string + tags_ids: Array + thumbnail_url: string + title: string +} + +export interface ITwitchVideo { + created_at: string + description: string + duration: string + id: string + language: string + muted_segments: Array<{ duration: number; offset: number }> + published_at: string + stream_id: string | null + thumbnail_url: string + title: string + type: string + url: string + user_id: string + user_login: string + user_name: string + view_count: number + viewable: string +} + +export interface ITwitchClip { + broadcaster_id: string + broadcaster_name: string + created_at: string + creator_id: string + creator_name: string + duration: number + embed_url: string + game_id: string + id: string + language: string + thumbnail_url: string + title: string + url: string + video_id: string + view_count: number + vod_offset: number +} + +export const enum TwitchResources { + host = 'https://api.twitch.tv', + clips = '/helix/clips', + followed = '/helix/streams/followed', + follows = '/helix/channels/followed', + searchChannels = '/helix/search/channels', + users = '/helix/users', + videos = '/helix/videos' +} + +export interface ITwitchGetUserVideosQueryParams extends querystring.ParsedUrlQueryInput { + after?: string + before?: string + first?: string + language?: string + period?: string + sort?: 'time' | 'trending' | 'views' + type?: TwitchVideoType +} + +export interface ITwitchGetClipsQueryParams extends querystring.ParsedUrlQueryInput { + after?: string + before?: string + ended_at?: string + first?: number + started_at?: string +} + +export enum TwitchVideoType { + all = 'all', + archive = 'archive', + highlight = 'highlight', + upload = 'upload' +} + +export const enum TwitchMediaType { + clip = 'clip', + video = 'video' +} + +/** + * Service for working with Twitch.tv API + * + * @remarks + * Twitch API Docs- {@link https://dev.twitch.tv/docs} + */ +@Singleton +class TwitchService extends ApiService implements ITwitchService { + /** A user authenticated with a Bearer token. */ + private _authUser: ITwitchUser | undefined + + constructor(host: string, headers: OutgoingHttpHeaders) { + super(host, headers) + } + + /** + * Gets information about a user authenticated with a Bearer token. + * + * @remarks + * Twitch API Reference Get Users - {@link https://dev.twitch.tv/docs/api/reference#get-users} + * + * @returns user information. + */ + public async getAuthUser(): Promise { + if (this._authUser) return this._authUser + + const { data } = await this.get, ITwitchError>(TwitchResources.users) + this._authUser = data[0] + + return this._authUser + } + + /** + * Gets clip information by broadcaster ID. + * + * @remarks + * Twitch API Reference Get Clips - {@link https://dev.twitch.tv/docs/api/reference#get-clips} + * + * @param broadcasterId - The ID of a broadcaster whose clips are being requested. + * @param queryParams - Optional query params {@link ITwitchGetClipsQueryParams}. + * @returns a list of clips. + */ + public async getClips(broadcasterId: string, queryParams?: ITwitchGetClipsQueryParams): Promise> { + if (!broadcasterId) return [] + + const { data } = await this.get>( + `${TwitchResources.clips}?broadcaster_id=${broadcasterId}${ + queryParams ? '&' + querystring.stringify(queryParams) : '' + }` + ) + + return data + } + + /** + * Gets information about live streams belonging to channels that the authenticated user follows. + * + * @remarks + * Twitch API Reference Get Followed Streams - {@link https://dev.twitch.tv/docs/api/reference#get-followed-streams} + * + * @param userId - the User ID in the bearer token. + * @returns information about live streams. + */ + public async getLiveFollowedStreams(userId: string | number): Promise> { + const { data } = await this.get, ITwitchError>( + `${TwitchResources.followed}?user_id=${userId}` + ) + return data + } + + /** + * Gets information about users who are being followed by a user. + * + * @remarks + * Twitch API Reference Get Users Follows - {@link https://dev.twitch.tv/docs/api/reference#get-users-follows} + * + * @param userId - the user ID. + * @returns information about users who are being followed by the from_id user. + */ + public async getUserFollows(userId: string | number): Promise> { + const { data } = await this.get, ITwitchError>( + `${TwitchResources.follows}?user_id=${userId}` + ) + return data + } + + /** + * Gets information about one or more specified Twitch users. + * + * @remarks + * Twitch API Reference Get Users - {@link https://dev.twitch.tv/docs/api/reference#get-users} + * + * @param userIDsOrLogins - the list of user IDs or logins. + * @returns returns a list with information about Twitch users. + */ + public async getUsers(userIDsOrLogins: Array): Promise> { + if (userIDsOrLogins.length === 0) return [] + + const queryParams: string = userIDsOrLogins + .reduce((prev: string, cur: string) => `${prev}${isNaN(Number(cur)) ? 'login=' : 'id='}${cur}&`, '') + .slice(0, -1) + + const { data } = await this.get, ITwitchError>( + `${TwitchResources.users}?${queryParams}` + ) + return data + } + + /** + * Gets video information by user ID. + * + * @remarks + * Twitch API Reference Get Videos - {@link https://dev.twitch.tv/docs/api/reference#get-videos} + * + * @param userId - The user ID. + * @param queryParams - Optional query params. + * @returns a list of user videos. + */ + public async getUserVideos( + userId: string, + queryParams?: ITwitchGetUserVideosQueryParams + ): Promise> { + if (!userId) return [] + + const { data } = await this.get>( + `${TwitchResources.videos}?user_id=${encodeURI(userId)}${ + queryParams ? '&' + querystring.stringify(queryParams) : '' + }` + ) + + return data + } + + /** + * Searches for channels (users who have streamed within the past 6 months) + * that match a query via channel name or description either entirely or partially. + * + * @remarks + * Twitch API Reference Search Channels - {@link https://dev.twitch.tv/docs/api/reference#search-channels} + * + * @param query - The search query. + * @returns a list of channels (users who have streamed within the past 6 months). + */ + public async searchChannels(query: string): Promise> { + if (query === '') return [] + + const { data } = await this.get>( + `${TwitchResources.searchChannels}?query=${encodeURI(query)}` + ) + + return data + } +} + +export default new TwitchService(TwitchResources.host, { + Authorization: `Bearer ${authToken}`, + 'Client-Id': clientId +})