Skip to content

Commit

Permalink
Merge pull request #70 from jwplayer/feat/url-signing-and-drm
Browse files Browse the repository at this point in the history
Feat / add playlist and media entitlement
  • Loading branch information
ChristiaanScheermeijer authored May 30, 2022
2 parents cef4ca2 + 78d29a2 commit 8839d0b
Show file tree
Hide file tree
Showing 27 changed files with 293 additions and 138 deletions.
1 change: 1 addition & 0 deletions .commitlintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ module.exports = {
'menu',
'payment',
'e2e',
'signing',
'entitlement',
],
],
Expand Down
5 changes: 4 additions & 1 deletion src/containers/Cinema/Cinema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ 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 { usePlaylistItemCallback } from '#src/hooks/usePlaylistItemCallback';

type Props = {
item: PlaylistItem;
Expand All @@ -38,6 +39,7 @@ const Cinema: React.FC<Props> = ({ 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 handlePlaylistItemCallback = usePlaylistItemCallback();

const getProgress = useCallback((): VideoProgress | null => {
if (!playerRef.current) return null;
Expand Down Expand Up @@ -158,6 +160,7 @@ const Cinema: React.FC<Props> = ({ item, onPlay, onPause, onComplete, onUserActi
};

playerRef.current.on('beforePlay', handleBeforePlay);
playerRef.current.setPlaylistItemCallback(handlePlaylistItemCallback);
};

if (playerRef.current) {
Expand All @@ -167,7 +170,7 @@ const Cinema: React.FC<Props> = ({ 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 () => {
Expand Down
14 changes: 4 additions & 10 deletions src/containers/Playlist/PlaylistContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React, { useEffect } from 'react';

import { PersonalShelf, PersonalShelves } from '#src/enum/PersonalShelf';
import usePlaylist, { UsePlaylistResult } from '#src/hooks/usePlaylist';
import usePlaylist 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;
Expand All @@ -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);

} = usePlaylist(playlistId, { page_limit: PLAYLIST_LIMIT.toString() }, !isAlternativeShelf, true);
let playlist = fetchedPlaylist;

const favoritesPlaylist = useFavoritesStore((state) => state.getPlaylist());
Expand All @@ -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 });
};

Expand Down
47 changes: 47 additions & 0 deletions src/hooks/useContentProtection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useQuery } from 'react-query';

import { getPublicToken } from '#src/services/entitlement.service';
import { useConfigStore } from '#src/stores/ConfigStore';
import type { GetPlaylistParams } from '#types/playlist';
import type { GetMediaParams } from '#types/media';

const useContentProtection = <T>(
type: EntitlementType,
id: string | undefined,
callback: (token?: string, drmPolicyId?: string) => Promise<T | undefined>,
params: GetPlaylistParams | GetMediaParams = {},
enabled: boolean = true,
placeholderData?: T,
) => {
const signingConfig = useConfigStore((store) => store.config.contentSigningService);
const host = signingConfig?.host;
const drmPolicyId = signingConfig?.drmPolicyId;
const drmEnabled = !!drmPolicyId;
const signingEnabled = !!host && drmEnabled;

const { data: token, isLoading } = useQuery(
['token', type, id, params],
() => {
// we only want to sign public media/playlist URLs when DRM is enabled
if (!!id && !!host && drmEnabled) {
const { host, drmPolicyId } = signingConfig;

return getPublicToken(host, type, id, undefined, params, drmPolicyId);
}
},
{ enabled: signingEnabled && enabled && !!id, keepPreviousData: false, staleTime: 15 * 60 * 1000 },
);

const queryResult = useQuery<T | undefined>([type, id, params, token], async () => callback(token, drmPolicyId), {
enabled: !!id && enabled && (!signingEnabled || !!token),
placeholderData: placeholderData,
retry: type === 'media',
});

return {
...queryResult,
isLoading: isLoading || queryResult.isLoading,
};
};

export default useContentProtection;
2 changes: 2 additions & 0 deletions src/hooks/useEntitlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ const useEntitlement: UseEntitlement = (playlistItem) => {
const isPreEntitled = playlistItem && !isLocked(accessModel, !!user, !!subscription, playlistItem);
const mediaOffers = playlistItem?.mediaOffers || [];

// This entitlement query is invalidated by adding all transaction IDs to the queryKey. Perhaps a more optimal way is
// to invalidate the query cache after the payment.
const mediaEntitlementQueries = useQueries(
mediaOffers.map(({ offerId }) => ({
queryKey: ['mediaOffer', offerId, transactions?.map((t) => t.transactionId).join(',')],
Expand Down
31 changes: 31 additions & 0 deletions src/hooks/useEventCallback.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends (...args: any[]) => unknown>(callback?: T): T => {
const fnRef = useRef(() => {
throw new Error('Callback called in render');
}) as unknown as React.MutableRefObject<T | undefined>;

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;
16 changes: 5 additions & 11 deletions src/hooks/useMedia.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
import { UseBaseQueryResult, useQuery } from 'react-query';
import useContentProtection from '#src/hooks/useContentProtection';
import { getMediaById } from '#src/services/api.service';

import { getMediaById } from '../services/api.service';
export default function useMedia(mediaId: string, enabled: boolean = true) {
const callback = (token?: string, drmPolicyId?: string) => getMediaById(mediaId, token, drmPolicyId);

import type { PlaylistItem } from '#types/playlist';

export type UseMediaResult<TData = PlaylistItem, TError = unknown> = UseBaseQueryResult<TData, TError>;

export default function useMedia(mediaId: string, enabled: boolean = true): UseMediaResult {
return useQuery(['media', mediaId], () => getMediaById(mediaId), {
enabled: !!mediaId && enabled,
keepPreviousData: true,
});
return useContentProtection('media', mediaId, callback, {}, enabled);
}
35 changes: 17 additions & 18 deletions src/hooks/usePlaylist.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import { UseBaseQueryResult, useQuery } from 'react-query';
import useContentProtection from '#src/hooks/useContentProtection';
import { generatePlaylistPlaceholder } from '#src/utils/collection';
import type { GetPlaylistParams } from '#types/playlist';
import { getPlaylistById } from '#src/services/api.service';
import { queryClient } from '#src/providers/QueryProvider';

import { generatePlaylistPlaceholder } from '../utils/collection';
import { getPlaylistById } from '../services/api.service';
const placeholderData = generatePlaylistPlaceholder(30);

import type { Playlist } from '#types/playlist';
export default function usePlaylist(playlistId: string, params: GetPlaylistParams = {}, enabled: boolean = true, usePlaceholderData: boolean = true) {
const callback = async (token?: string, drmPolicyId?: string) => {
const playlist = await getPlaylistById(playlistId, { token, ...params }, drmPolicyId);

const placeholderData = generatePlaylistPlaceholder(30);
// This pre-caches all playlist items and makes navigating a lot faster. This doesn't work when DRM is enabled
// because of the token mechanism.
playlist?.playlist?.forEach((playlistItem) => {
queryClient.setQueryData(['media', playlistItem.mediaid, {}, undefined], playlistItem);
});

export type UsePlaylistResult<TData = Playlist, TError = unknown> = UseBaseQueryResult<TData, TError>;
return playlist;
};

export default function usePlaylist(
playlistId: string,
relatedMediaId?: string,
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,
});
return useContentProtection('playlist', playlistId, callback, params, enabled, usePlaceholderData ? placeholderData : undefined);
}
27 changes: 27 additions & 0 deletions src/hooks/usePlaylistItemCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { getMediaToken } from '../services/entitlement.service';
import { useAccountStore } from '../stores/AccountStore';
import { useConfigStore } from '../stores/ConfigStore';

import { getMediaById } from '#src/services/api.service';
import useEventCallback from '#src/hooks/useEventCallback';
import type { PlaylistItem } from '#types/playlist';

export const usePlaylistItemCallback = () => {
const auth = useAccountStore(({ auth }) => auth);
const signingConfig = useConfigStore((state) => state.config?.contentSigningService);

return useEventCallback(async (item: PlaylistItem) => {
const jwt = auth?.jwt;
const host = signingConfig?.host;
const drmPolicyId = signingConfig?.drmPolicyId;
const signingEnabled = !!host;

if (!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.
const token = await getMediaToken(host, item.mediaid, jwt, {}, drmPolicyId);

return await getMediaById(item.mediaid, token, drmPolicyId);
});
};
14 changes: 0 additions & 14 deletions src/hooks/useRecommendationsPlaylist.ts

This file was deleted.

18 changes: 0 additions & 18 deletions src/hooks/useSearchPlaylist.ts

This file was deleted.

7 changes: 1 addition & 6 deletions src/providers/ConfigProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,7 @@ const ConfigProvider: FunctionComponent<ProviderProps> = ({ children, configLoca
return 'SVOD';
};

return (
<ConfigContext.Provider value={config}>
{loading ? <LoadingOverlay /> : null}
{children}
</ConfigContext.Provider>
);
return <ConfigContext.Provider value={config}>{loading ? <LoadingOverlay /> : children}</ConfigContext.Provider>;
};

export default ConfigProvider;
4 changes: 2 additions & 2 deletions src/providers/QueryProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';

const queryClient = new QueryClient({
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 3600,
staleTime: 60 * 60 * 1000,
refetchOnWindowFocus: false,
retryOnMount: false,
},
Expand Down
4 changes: 2 additions & 2 deletions src/screens/Movie/Movie.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ 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 { useConfigStore } from '#src/stores/ConfigStore';
import { useAccountStore } from '#src/stores/AccountStore';
import { addConfigParamToUrl } from '#src/utils/configOverride';
import { removeItem, saveItem } from '#src/stores/FavoritesController';
import usePlaylist from '#src/hooks/usePlaylist';
import useEntitlement from '#src/hooks/useEntitlement';
import StartWatchingButton from '#src/containers/StartWatchingButton/StartWatchingButton';

Expand Down Expand Up @@ -52,7 +52,7 @@ const Movie = ({ match, location }: RouteComponentProps<MovieRouteParams>): JSX.
const { isLoading, error, data: item } = useMedia(id);
useBlurImageUpdater(item);
const { data: trailerItem } = useMedia(item?.trailerId || '');
const { data: playlist } = useRecommendedPlaylist(recommendationsPlaylist || '', item);
const { data: playlist } = usePlaylist(recommendationsPlaylist || '', { related_media_id: id });

// Favorite
const isFavorited = useFavoritesStore((state) => !!item && state.hasItem(item));
Expand Down
5 changes: 3 additions & 2 deletions src/screens/Search/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -36,7 +37,7 @@ const Search: React.FC<RouteComponentProps<SearchRouteParams>> = ({
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 || '' }, true, !!query);

const updateBlurImage = useBlurImageUpdater(playlist);

Expand Down
9 changes: 2 additions & 7 deletions src/screens/Series/Series.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,7 @@ const Series = ({ match, location }: RouteComponentProps<SeriesRouteParams>): JS
const { isLoading, error, data: item } = useMedia(episodeId);
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<string>('');
const filters = getFiltersFromSeries(seriesPlaylist.playlist);
const filteredPlaylist = useMemo(() => filterSeries(seriesPlaylist.playlist, seasonFilter), [seriesPlaylist, seasonFilter]);
Expand Down Expand Up @@ -145,8 +141,7 @@ const Series = ({ match, location }: RouteComponentProps<SeriesRouteParams>): JS
{item.tags?.split(',').map((tag) => (
<meta property="og:video:tag" content={tag} key={tag} />
))}
{seriesPlaylist && item ?
<script type="application/ld+json">{generateEpisodeJSONLD(seriesPlaylist, item)}</script> : null}
{seriesPlaylist && item ? <script type="application/ld+json">{generateEpisodeJSONLD(seriesPlaylist, item)}</script> : null}
</Helmet>
<VideoComponent
title={seriesPlaylist.title}
Expand Down
Loading

0 comments on commit 8839d0b

Please sign in to comment.