diff --git a/.commitlintrc.js b/.commitlintrc.js index 11d561b44..3ce1651c6 100644 --- a/.commitlintrc.js +++ b/.commitlintrc.js @@ -22,6 +22,7 @@ module.exports = { 'menu', 'payment', 'e2e', + 'signing', ], ], }, diff --git a/src/containers/Cinema/Cinema.tsx b/src/containers/Cinema/Cinema.tsx index a7cbcfead..d66ac89d9 100644 --- a/src/containers/Cinema/Cinema.tsx +++ b/src/containers/Cinema/Cinema.tsx @@ -15,6 +15,8 @@ import type { PlaylistItem } from '#types/playlist'; import type { Config } from '#types/Config'; import { saveItem } from '#src/stores/WatchHistoryController'; import type { VideoProgress } from '#types/video'; +import useEventCallback from '#src/hooks/useEventCallback'; +import { usePlaylistItemCallback } from '#src/hooks/usePlaylistItemCallback'; type Props = { item: PlaylistItem; @@ -38,6 +40,9 @@ const Cinema: React.FC = ({ item, onPlay, onPause, onComplete, onUserActi const scriptUrl = `https://content.jwplatform.com/libraries/${config.player}.js`; const enableWatchHistory = config.options.enableContinueWatching && !isTrailer; const setPlayer = useOttAnalytics(item, feedId); + const playlistItemCallback = usePlaylistItemCallback(); + + const handlePlaylistItemCallback = useEventCallback(playlistItemCallback); const getProgress = useCallback((): VideoProgress | null => { if (!playerRef.current) return null; @@ -158,6 +163,7 @@ const Cinema: React.FC = ({ item, onPlay, onPause, onComplete, onUserActi }; playerRef.current.on('beforePlay', handleBeforePlay); + playerRef.current.setPlaylistItemCallback(handlePlaylistItemCallback); }; if (playerRef.current) { @@ -167,7 +173,7 @@ const Cinema: React.FC = ({ item, onPlay, onPause, onComplete, onUserActi if (libLoaded) { initializePlayer(); } - }, [libLoaded, item, onPlay, onPause, onUserActive, onUserInActive, onComplete, config.player, enableWatchHistory, setPlayer]); + }, [libLoaded, item, onPlay, onPause, onUserActive, onUserInActive, onComplete, config.player, enableWatchHistory, setPlayer, handlePlaylistItemCallback]); useEffect(() => { return () => { diff --git a/src/containers/Playlist/PlaylistContainer.tsx b/src/containers/Playlist/PlaylistContainer.tsx index 7661776c2..74647976a 100644 --- a/src/containers/Playlist/PlaylistContainer.tsx +++ b/src/containers/Playlist/PlaylistContainer.tsx @@ -5,7 +5,7 @@ import usePlaylist, { UsePlaylistResult } from '#src/hooks/usePlaylist'; import { useWatchHistoryStore } from '#src/stores/WatchHistoryStore'; import { useFavoritesStore } from '#src/stores/FavoritesStore'; import { PLAYLIST_LIMIT } from '#src/config'; -import type { Playlist, PlaylistItem } from '#types/playlist'; +import type { Playlist } from '#types/playlist'; type ChildrenParams = { playlist: Playlist; @@ -16,21 +16,19 @@ type ChildrenParams = { type Props = { playlistId: string; - relatedItem?: PlaylistItem; onPlaylistUpdate?: (playlist: Playlist) => void; children: (childrenParams: ChildrenParams) => JSX.Element; style?: React.CSSProperties; showEmpty?: boolean; }; -const PlaylistContainer = ({ playlistId, relatedItem, onPlaylistUpdate, style, children, showEmpty = false }: Props): JSX.Element | null => { +const PlaylistContainer = ({ playlistId, onPlaylistUpdate, style, children, showEmpty = false }: Props): JSX.Element | null => { const isAlternativeShelf = PersonalShelves.includes(playlistId as PersonalShelf); const { isLoading, error, data: fetchedPlaylist = { title: '', playlist: [] }, - }: UsePlaylistResult = usePlaylist(playlistId, relatedItem?.mediaid, !isAlternativeShelf && !!playlistId, true, PLAYLIST_LIMIT); - + }: UsePlaylistResult = usePlaylist(playlistId, { page_limit: PLAYLIST_LIMIT.toString() }, !isAlternativeShelf, true); let playlist = fetchedPlaylist; const favoritesPlaylist = useFavoritesStore((state) => state.getPlaylist()); @@ -48,10 +46,6 @@ const PlaylistContainer = ({ playlistId, relatedItem, onPlaylistUpdate, style, c return null; } - if (relatedItem && !playlist.playlist.some(({ mediaid }) => mediaid === relatedItem.mediaid)) { - playlist.playlist.unshift(relatedItem); - } - return children({ playlist, isLoading, error, style }); }; diff --git a/src/hooks/useEventCallback.ts b/src/hooks/useEventCallback.ts new file mode 100644 index 000000000..3c4d772b7 --- /dev/null +++ b/src/hooks/useEventCallback.ts @@ -0,0 +1,31 @@ +import React, { useCallback, useLayoutEffect, useRef } from 'react'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * The `useEventCallback` hook can be compared to the `useCallback` hook but without dependencies. It is a "shortcut" + * to prevent re-renders based on callback changes due to dependency changes. This can be useful to improve the + * performance or to prevent adding/removing event listeners to third-party libraries such as JW Player. + * + * @see {https://reactjs.org/docs/hooks-faq.html#how-to-avoid-passing-callbacks-down} + * + * @param {function} [callback] + */ +const useEventCallback = unknown>(callback?: T): T => { + const fnRef = useRef(() => { + throw new Error('Callback called in render'); + }) as unknown as React.MutableRefObject; + + useLayoutEffect(() => { + fnRef.current = callback; + }, [callback]); + + // @ts-ignore + // ignore since we just want to pass all arguments to the callback function (which we don't know) + return useCallback((...args) => { + if (typeof fnRef.current === 'function') { + return fnRef.current(...args); + } + }, []); +}; + +export default useEventCallback; diff --git a/src/hooks/useMedia.ts b/src/hooks/useMedia.ts index 2121117d5..0b30171d5 100644 --- a/src/hooks/useMedia.ts +++ b/src/hooks/useMedia.ts @@ -1,14 +1,35 @@ import { UseBaseQueryResult, useQuery } from 'react-query'; -import { getMediaById } from '../services/api.service'; +import { getDRMMediaById, getMediaById } from '../services/api.service'; import type { PlaylistItem } from '#types/playlist'; +import { getPublicToken } from '#src/services/entitlement.service'; +import { useAccountStore } from '#src/stores/AccountStore'; +import { useConfigStore } from '#src/stores/ConfigStore'; export type UseMediaResult = UseBaseQueryResult; export default function useMedia(mediaId: string, enabled: boolean = true): UseMediaResult { - return useQuery(['media', mediaId], () => getMediaById(mediaId), { - enabled: !!mediaId && enabled, - keepPreviousData: true, - }); + const jwt = useAccountStore((store) => store.auth?.jwt); + const signingConfig = useConfigStore((store) => store.config.contentSigningService); + + return useQuery( + ['media', mediaId], + async () => { + const drmEnabled = !!signingConfig?.host && !!signingConfig?.drmEnabled && !!signingConfig?.drmPolicyId; + + if (drmEnabled && signingConfig?.drmEnabled && signingConfig?.drmPolicyId) { + const { host, drmPolicyId } = signingConfig; + const token = await getPublicToken(host, 'media', mediaId, drmPolicyId, {}, jwt); + + return getDRMMediaById(mediaId, signingConfig.drmPolicyId, token); + } + + return getMediaById(mediaId); + }, + { + enabled: !!mediaId && enabled, + keepPreviousData: true, + }, + ); } diff --git a/src/hooks/usePlaylist.ts b/src/hooks/usePlaylist.ts index b58569214..8da1f1470 100644 --- a/src/hooks/usePlaylist.ts +++ b/src/hooks/usePlaylist.ts @@ -1,24 +1,59 @@ import { UseBaseQueryResult, useQuery } from 'react-query'; import { generatePlaylistPlaceholder } from '../utils/collection'; -import { getPlaylistById } from '../services/api.service'; +import { getDRMPlaylistById, getPlaylistById } from '../services/api.service'; -import type { Playlist } from '#types/playlist'; +import type { Playlist, PlaylistParams } from '#types/playlist'; +import { useConfigStore } from '#src/stores/ConfigStore'; +import { getPublicToken } from '#src/services/entitlement.service'; +import { useAccountStore } from '#src/stores/AccountStore'; const placeholderData = generatePlaylistPlaceholder(30); export type UsePlaylistResult = UseBaseQueryResult; -export default function usePlaylist( +const filterRelatedMediaItem = (playlist: Playlist | undefined, relatedMediaId?: string): Playlist | undefined => { + if (playlist?.playlist && relatedMediaId) { + playlist.playlist = playlist.playlist.filter((playlistItem) => playlistItem.mediaid !== relatedMediaId); + } + + return playlist; +}; + +export default function usePlaylist ( playlistId: string, - relatedMediaId?: string, + params: PlaylistParams = {}, enabled: boolean = true, usePlaceholderData: boolean = true, - limit?: number, ): UsePlaylistResult { - return useQuery(['playlist', playlistId, relatedMediaId], () => getPlaylistById(playlistId, relatedMediaId, limit), { - enabled: !!playlistId && enabled, - placeholderData: usePlaceholderData ? placeholderData : undefined, - retry: false, - }); + const jwt = useAccountStore((store) => store.auth?.jwt); + const signingConfig = useConfigStore((store) => store.config.contentSigningService); + + return useQuery( + ['playlist', playlistId, params], + async () => { + const drmEnabled = !!signingConfig?.host && !!signingConfig?.drmEnabled && !!signingConfig?.drmPolicyId; + + if (drmEnabled && signingConfig?.drmEnabled && signingConfig?.drmPolicyId) { + const { host, drmPolicyId } = signingConfig; + const token = await getPublicToken(host, 'playlist', playlistId, jwt, params, drmPolicyId); + + const playlist = await getDRMPlaylistById(playlistId, signingConfig.drmPolicyId, { + ...params, + token, + }); + + return filterRelatedMediaItem(playlist, params.related_media_id); + } + + const playlist = await getPlaylistById(playlistId, {}).then(filterRelatedMediaItem); + + return filterRelatedMediaItem(playlist, params.related_media_id); + }, + { + enabled: !!playlistId && enabled, + placeholderData: usePlaceholderData ? placeholderData : undefined, + retry: false, + }, + ); } diff --git a/src/hooks/usePlaylistItemCallback.ts b/src/hooks/usePlaylistItemCallback.ts new file mode 100644 index 000000000..1581b0a92 --- /dev/null +++ b/src/hooks/usePlaylistItemCallback.ts @@ -0,0 +1,34 @@ +import { useCallback } from 'react'; + +import { getMediaToken } from '../services/entitlement.service'; +import { useAccountStore } from '../stores/AccountStore'; +import { useConfigStore } from '../stores/ConfigStore'; + +import { getDRMMediaById, getMediaById } from '#src/services/api.service'; +import type { PlaylistItem } from '#types/playlist'; + +export const usePlaylistItemCallback = () => { + const { auth } = useAccountStore(({ auth, subscription }) => ({ auth, subscription })); + const signingConfig = useConfigStore((state) => state.config?.contentSigningService); + + return useCallback( + async (item: PlaylistItem) => { + const jwt = auth?.jwt; + const signingEnabled = !!signingConfig?.host; + const drmEnabled = signingEnabled && signingConfig?.drmEnabled && signingConfig?.drmPolicyId; + + if (!signingConfig && !signingEnabled) return item; + + // if signing is enabled, we need to sign the media item first. Assuming that the media item given to the player + // isn't signed. An alternative way is to + const { host, drmPolicyId } = signingConfig; + + const token = await getMediaToken({ host, drmPolicyId, id: item.mediaid, jwt, params: {} }); + + if (drmEnabled && drmPolicyId) return getDRMMediaById(item.mediaid, drmPolicyId, token); + + return await getMediaById(item.mediaid, token); + }, + [auth], + ); +}; diff --git a/src/hooks/useRecommendationsPlaylist.ts b/src/hooks/useRecommendationsPlaylist.ts deleted file mode 100644 index a62b09c80..000000000 --- a/src/hooks/useRecommendationsPlaylist.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { UseBaseQueryResult, useQuery } from 'react-query'; - -import { getPlaylistById } from '../services/api.service'; - -import type { Playlist, PlaylistItem } from '#types/playlist'; - -export type UseRecommendationsPlaylistResult = UseBaseQueryResult; - -export default function useRecommendedPlaylist(playlistId: string, relatedItem?: PlaylistItem, enabled: boolean = true): UseRecommendationsPlaylistResult { - return useQuery( - ['recommendationsPlaylist', playlistId, relatedItem?.mediaid], - () => - getPlaylistById(playlistId, relatedItem?.mediaid).then((playlist) => { - if (playlist?.playlist && relatedItem && !playlist.playlist.some((item) => item.mediaid === relatedItem.mediaid)) { - playlist.playlist.unshift(relatedItem); - } - - return playlist; - }), - { - enabled: !!playlistId && !!relatedItem && enabled, - retry: false, - }, - ); -} diff --git a/src/hooks/useSearchPlaylist.ts b/src/hooks/useSearchPlaylist.ts deleted file mode 100644 index 255dc6a5a..000000000 --- a/src/hooks/useSearchPlaylist.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { UseBaseQueryResult, useQuery } from 'react-query'; - -import { generatePlaylistPlaceholder } from '../utils/collection'; -import { getSearchPlaylist } from '../services/api.service'; - -import type { Playlist } from '#types/playlist'; - -const placeholderData = generatePlaylistPlaceholder(); - -export type UseSearchPlaylistResult = UseBaseQueryResult; - -export default function useSearchPlaylist(playlistId: string, query: string, usePlaceholderData: boolean = true): UseSearchPlaylistResult { - return useQuery( - ['playlist', playlistId, query], - () => { - return getSearchPlaylist(playlistId, query); - }, - { - enabled: !!playlistId && !!query, - placeholderData: usePlaceholderData ? placeholderData : undefined, - keepPreviousData: true, - }, - ); -} diff --git a/src/providers/ConfigProvider.tsx b/src/providers/ConfigProvider.tsx index bea550935..fee9079c2 100644 --- a/src/providers/ConfigProvider.tsx +++ b/src/providers/ConfigProvider.tsx @@ -105,12 +105,7 @@ const ConfigProvider: FunctionComponent = ({ children, configLoca return 'SVOD'; }; - return ( - - {loading ? : null} - {children} - - ); + return {loading ? : children}; }; export default ConfigProvider; diff --git a/src/screens/Movie/Movie.tsx b/src/screens/Movie/Movie.tsx index 0fededf26..c395ee210 100644 --- a/src/screens/Movie/Movie.tsx +++ b/src/screens/Movie/Movie.tsx @@ -17,7 +17,6 @@ import useMedia from '#src/hooks/useMedia'; import { generateMovieJSONLD } from '#src/utils/structuredData'; import { copyToClipboard } from '#src/utils/dom'; import LoadingOverlay from '#src/components/LoadingOverlay/LoadingOverlay'; -import useRecommendedPlaylist from '#src/hooks/useRecommendationsPlaylist'; import { useWatchHistoryStore } from '#src/stores/WatchHistoryStore'; import { useConfigStore } from '#src/stores/ConfigStore'; import { useAccountStore } from '#src/stores/AccountStore'; @@ -26,6 +25,7 @@ import { isAllowedToWatch } from '#src/utils/cleeng'; import { addConfigParamToUrl } from '#src/utils/configOverride'; import { useFavoritesStore } from '#src/stores/FavoritesStore'; import { removeItem, saveItem } from '#src/stores/FavoritesController'; +import usePlaylist from '#src/hooks/usePlaylist'; type MovieRouteParams = { id: string; @@ -52,7 +52,7 @@ const Movie = ({ match, location }: RouteComponentProps): JSX. const itemRequiresSubscription = item?.requiresSubscription !== 'false'; useBlurImageUpdater(item); const { data: trailerItem } = useMedia(item?.trailerId || ''); - const { data: playlist } = useRecommendedPlaylist(recommendationsPlaylist || '', item); + const { data: playlist } = usePlaylist(recommendationsPlaylist || '', { related_media_id: id }); const isFavorited = useFavoritesStore((state) => !!item && state.hasItem(item)); diff --git a/src/screens/Search/Search.tsx b/src/screens/Search/Search.tsx index d40719b84..97fc09386 100644 --- a/src/screens/Search/Search.tsx +++ b/src/screens/Search/Search.tsx @@ -13,12 +13,13 @@ import type { PlaylistItem } from '../../../types/playlist'; import CardGrid from '../../components/CardGrid/CardGrid'; import { cardUrl } from '../../utils/formatting'; import useFirstRender from '../../hooks/useFirstRender'; -import useSearchPlaylist from '../../hooks/useSearchPlaylist'; import { useAccountStore } from '../../stores/AccountStore'; import { useConfigStore } from '../../stores/ConfigStore'; import styles from './Search.module.scss'; +import usePlaylist from '#src/hooks/usePlaylist'; + type SearchRouteParams = { query: string; }; @@ -36,7 +37,7 @@ const Search: React.FC> = ({ const searchQuery = useUIStore((state) => state.searchQuery); const { updateSearchQuery } = useSearchQueryUpdater(); const history = useHistory(); - const { isFetching, error, data: { playlist } = { playlist: [] } } = useSearchPlaylist(searchPlaylist || '', query, firstRender); + const { isFetching, error, data: { playlist } = { playlist: [] } } = usePlaylist(searchPlaylist || '', { search: query }, !firstRender, !!query); const updateBlurImage = useBlurImageUpdater(playlist); diff --git a/src/screens/Series/Series.tsx b/src/screens/Series/Series.tsx index eac7dcfbb..305830ebd 100644 --- a/src/screens/Series/Series.tsx +++ b/src/screens/Series/Series.tsx @@ -54,7 +54,7 @@ const Series = ({ match, location }: RouteComponentProps): JS const itemRequiresSubscription = item?.requiresSubscription !== 'false'; useBlurImageUpdater(item); const { data: trailerItem } = useMedia(item?.trailerId || ''); - const { isLoading: playlistIsLoading, error: playlistError, data: seriesPlaylist = { title: '', playlist: [] } } = usePlaylist(id, undefined, true, false); + const { isLoading: playlistIsLoading, error: playlistError, data: seriesPlaylist = { title: '', playlist: [] } } = usePlaylist(id, {}, true, false); const [seasonFilter, setSeasonFilter] = useState(''); const filters = getFiltersFromSeries(seriesPlaylist.playlist); const filteredPlaylist = useMemo(() => filterSeries(seriesPlaylist.playlist, seasonFilter), [seriesPlaylist, seasonFilter]); diff --git a/src/services/api.service.ts b/src/services/api.service.ts index 21ae29788..5aa6db025 100644 --- a/src/services/api.service.ts +++ b/src/services/api.service.ts @@ -1,5 +1,5 @@ import { addQueryParams } from '../utils/formatting'; -import type { Playlist, PlaylistItem } from '../../types/playlist'; +import type { Playlist, PlaylistItem, PlaylistParams } from '../../types/playlist'; import { API_BASE_URL } from '../config'; /** @@ -21,32 +21,50 @@ export const getDataOrThrow = async (response: Response) => { /** * Get playlist by id * @param {string} id - * @param relatedMediaId + * @param params */ -export const getPlaylistById = (id: string, relatedMediaId?: string, limit?: number): Promise => { - const url = addQueryParams(`${API_BASE_URL}/v2/playlists/${id}`, { - related_media_id: relatedMediaId, - page_limit: limit?.toString(), - }); +export const getPlaylistById = (id: string, params: PlaylistParams = {}): Promise => { + const url = addQueryParams(`${API_BASE_URL}/v2/playlists/${id}`, params); return fetch(url).then(getDataOrThrow); }; /** - * Get search playlist - * @param {string} playlistId - * @param {string} query + * Get DRM playlist by id */ -export const getSearchPlaylist = (playlistId: string, query: string): Promise => { - return fetch(`${API_BASE_URL}/v2/playlists/${playlistId}?search=${encodeURIComponent(query)}`).then(getDataOrThrow); +export const getDRMPlaylistById = async (id: string, policyId: string, params: PlaylistParams = {}): Promise => { + const url = addQueryParams(`${API_BASE_URL}/v2/playlists/${id}/drm/${policyId}`, params); + + return fetch(url).then(getDataOrThrow); }; /** * Get media by id * @param {string} id + * @param {string} [token] */ -export const getMediaById = (id: string): Promise => { - return fetch(`${API_BASE_URL}/v2/media/${id}`) +export const getMediaById = (id: string, token?: string): Promise => { + const url = addQueryParams(`${API_BASE_URL}/v2/media/${id}`, { + token, + }); + + return fetch(url) + .then((res) => getDataOrThrow(res) as Promise) + .then((data) => data.playlist[0]); +}; + +/** + * Get media by id + * @param {string} id + * @param {string} policyId + * @param {string} token + */ +export const getDRMMediaById = (id: string, policyId: string, token: string): Promise => { + const url = addQueryParams(`${API_BASE_URL}/v2/media/${id}/drm/${policyId}`, { + token, + }); + + return fetch(url) .then((res) => getDataOrThrow(res) as Promise) .then((data) => data.playlist[0]); }; diff --git a/src/services/entitlement.service.ts b/src/services/entitlement.service.ts new file mode 100644 index 000000000..baa6c7c6a --- /dev/null +++ b/src/services/entitlement.service.ts @@ -0,0 +1,23 @@ +const getToken = async (url: string, params: Record = {}, jwt?: string) => { + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: jwt ? `Bearer ${jwt}` : '', + }, + body: JSON.stringify(params), + }); + + const data = (await response.json()) as EntitlementResponse; + + if (!data.entitled) throw new Error('Unauthorized'); + + return data.token; +}; + +export const getMediaToken = (host: string, id: string, jwt?: string, params = {}, drmPolicyId?: string) => { + return getToken(`${host}/media/${id}/sign${drmPolicyId ? `/drm/${drmPolicyId}` : ''}`, params, jwt); +}; + +export const getPublicToken = (host: string, type: EntitlementType, id: string, jwt?: string, params = {}, drmPolicyId?: string) => { + return getToken(`${host}/${type}/${id}/sign_public${drmPolicyId ? `/drm/${drmPolicyId}` : ''}`, params, jwt); +}; diff --git a/types/Config.d.ts b/types/Config.d.ts index 4804d7917..63cef5e9c 100644 --- a/types/Config.d.ts +++ b/types/Config.d.ts @@ -20,6 +20,13 @@ export type Config = { options: Options; genres?: string[]; json?: Record; + contentSigningService?: ContentSigningConfig; +}; + +export type ContentSigningConfig = { + host: string; + drmEnabled?: boolean; + drmPolicyId?: string; }; export type Simple = { diff --git a/types/entitlement.d.ts b/types/entitlement.d.ts new file mode 100644 index 000000000..7fe777b5d --- /dev/null +++ b/types/entitlement.d.ts @@ -0,0 +1,12 @@ +type EntitlementJwt = { + resource: string; + exp: number; + iat: number; +}; + +type EntitlementResponse = { + entitled: boolean; + token: string; +}; + +type EntitlementType = 'media' | 'playlist' | 'library'; diff --git a/types/jwplayer.d.ts b/types/jwplayer.d.ts index bb7ed5364..8d5cb107a 100644 --- a/types/jwplayer.d.ts +++ b/types/jwplayer.d.ts @@ -1,7 +1,23 @@ import '@types/jwplayer'; +// missing events in typings +interface EventParams extends jwplayer.EventParams { + userActive: () => void; + userInactive: () => void; + nextClick: () => void; + pipEnter: () => void; + pipLeave: () => void; +} + export type JWPlayer = jwplayer.JWPlayer & { - on(event: 'userActive' | 'userInactive', callback: () => void); - off(event: 'userActive' | 'userInactive', callback: () => void); + on(event: TEvent, callback: jwplayer.EventCallback): JWPlayer; + on(event: jwplayer.NoParamEvent, callback: () => void): JWPlayer; + once(event: TEvent, callback: jwplayer.EventCallback): JWPlayer; + once(event: jwplayer.NoParamEvent, callback: () => void): JWPlayer; + off(event: keyof EventParams | jwplayer.NoParamEvent): JWPlayer; + off(event: jwplayer.NoParamEvent, callback: () => void): JWPlayer; + off(event: TEvent, callback: jwplayer.EventCallback): JWPlayer; setConfig({ playlist, autostart }); + setPlaylistItemCallback(callback?: (item: never) => Promise): void; + removePlaylistItemCallback(): void; }; diff --git a/types/playlist.d.ts b/types/playlist.d.ts index 88ec4a2b1..60a2b5845 100644 --- a/types/playlist.d.ts +++ b/types/playlist.d.ts @@ -45,6 +45,8 @@ export type Link = { last?: string; }; +export type PlaylistParams = { page_limit?: string; related_media_id?: string; token?: string; search?: string }; + export type Playlist = { description?: string; feed_instance_id?: string;