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 playlist and media entitlement #70

Merged
merged 18 commits into from
May 30, 2022
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e93a655
feat(signing): add playlist and media entitlement using a service
ChristiaanScheermeijer May 23, 2022
b29096d
chore: remove duplicate filterRelatedMediaItem invocation
ChristiaanScheermeijer May 23, 2022
c7ec2ef
chore: fix playlist params not being used
ChristiaanScheermeijer May 23, 2022
020a06a
fix: react query staleTime wrong value
ChristiaanScheermeijer May 23, 2022
1bc7d6b
chore: react query staleTime still wrong value
ChristiaanScheermeijer May 23, 2022
49c1dbe
chore: remove redundant useCallback in usePlaylistItemCallback
ChristiaanScheermeijer May 24, 2022
c94c031
chore: separate token query from useMedia and usePlaylist hooks
ChristiaanScheermeijer May 24, 2022
b402b26
chore: fix typescript errors
ChristiaanScheermeijer May 24, 2022
a59d0a7
chore: remove redundant drm api calls
ChristiaanScheermeijer May 24, 2022
e953172
chore: fix search not working in the first render
ChristiaanScheermeijer May 24, 2022
29ea99d
Merge branch 'develop' into feat/url-signing-and-drm
ChristiaanScheermeijer May 25, 2022
0a0e2c8
chore: implement url signing for getMediaByIds
ChristiaanScheermeijer May 25, 2022
cd1b912
refactor: rename useSignedUrl hook and refactor usage with callback
ChristiaanScheermeijer May 27, 2022
b78ce02
refactor: simplify contentSigning config
ChristiaanScheermeijer May 27, 2022
8d81808
Merge branch develop into feat/url-signing-and-drm
ChristiaanScheermeijer May 27, 2022
0d70e99
fix(signing): prevent signing when DRM is disabled
ChristiaanScheermeijer May 27, 2022
2b8b5ee
feat(project): cache media items from playlists
ChristiaanScheermeijer May 28, 2022
78d29a2
chore(project): remove redunant playlist filter function
ChristiaanScheermeijer May 30, 2022
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
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',
],
],
},
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
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;
20 changes: 12 additions & 8 deletions src/hooks/useMedia.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { UseBaseQueryResult, useQuery } from 'react-query';
import { useQuery } from 'react-query';

import { getMediaById } from '../services/api.service';
import { getMediaById } from '#src/services/api.service';
import useSignedUrl from '#src/hooks/useSignedUrl';

import type { PlaylistItem } from '#types/playlist';
export default function useMedia(mediaId: string, enabled: boolean = true) {
const { token, signingEnabled, drmEnabled, drmPolicyId, isLoading } = useSignedUrl('media', mediaId, {}, enabled);

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,
const queryResult = useQuery(['media', mediaId, token], () => getMediaById(mediaId, token, drmEnabled ? drmPolicyId : undefined), {
ChristiaanScheermeijer marked this conversation as resolved.
Show resolved Hide resolved
enabled: !!mediaId && enabled && (!signingEnabled || !!token),
keepPreviousData: true,
});

return {
...queryResult,
isLoading: isLoading || queryResult.isLoading,
};
}
56 changes: 37 additions & 19 deletions src/hooks/usePlaylist.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,42 @@
import { UseBaseQueryResult, useQuery } from 'react-query';
import { useQuery } from 'react-query';

import { generatePlaylistPlaceholder } from '../utils/collection';
import { getPlaylistById } from '../services/api.service';

import type { Playlist } from '#types/playlist';
import useSignedUrl from '#src/hooks/useSignedUrl';
import { getPlaylistById } from '#src/services/api.service';
import { generatePlaylistPlaceholder } from '#src/utils/collection';
import type { GetPlaylistParams, Playlist } from '#types/playlist';

const placeholderData = generatePlaylistPlaceholder(30);

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

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,
});
/**
* Filter out media item with the given id
*/
const filterMediaItem = (playlist: Playlist | undefined, mediaId?: string) => {
if (playlist?.playlist && mediaId) {
playlist.playlist = playlist.playlist.filter((playlistItem) => playlistItem.mediaid !== mediaId);
}

return playlist;
};

export default function usePlaylist(playlistId: string, params: GetPlaylistParams = {}, enabled: boolean = true, usePlaceholderData: boolean = true) {
const { token, signingEnabled, drmEnabled, drmPolicyId, isLoading } = useSignedUrl('playlist', playlistId, params, enabled);

const queryResult = useQuery(
['playlist', playlistId, params, token],
async () => {
const playlist = await getPlaylistById(playlistId, { ...params, token }, drmEnabled ? drmPolicyId : undefined);

return filterMediaItem(playlist, params.related_media_id);
},
dbudzins marked this conversation as resolved.
Show resolved Hide resolved
{
enabled: !!playlistId && enabled && (!signingEnabled || !!token),
placeholderData: usePlaceholderData ? placeholderData : undefined,
retry: false,
},
);

return {
...queryResult,
isLoading: isLoading || queryResult.isLoading,
};
}
28 changes: 28 additions & 0 deletions src/hooks/usePlaylistItemCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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 drmEnabled = !!drmPolicyId && !!signingConfig?.drmEnabled;
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, {}, drmEnabled ? drmPolicyId : undefined);

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

This file was deleted.

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

This file was deleted.

32 changes: 32 additions & 0 deletions src/hooks/useSignedUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useQuery } from 'react-query';
ChristiaanScheermeijer marked this conversation as resolved.
Show resolved Hide resolved

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

const useSignedUrl = (type: EntitlementType, id?: string, params: GetPlaylistParams | GetMediaParams = {}, enabled: boolean = true) => {
const jwt = useAccountStore((store) => store.auth?.jwt);
const signingConfig = useConfigStore((store) => store.config.contentSigningService);
const host = signingConfig?.host;
const drmPolicyId = signingConfig?.drmPolicyId;
const drmEnabled = !!drmPolicyId && !!signingConfig?.drmEnabled;
const signingEnabled = !!host && drmEnabled && !!drmPolicyId;

const { data: token, isLoading } = useQuery(
['token', type, id, params, jwt],
() => {
if (!!id && !!signingConfig?.host && signingConfig?.drmEnabled && !!signingConfig?.drmPolicyId) {
const { host, drmPolicyId } = signingConfig;

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

return { token, signingEnabled, drmEnabled, drmPolicyId, isLoading };
ChristiaanScheermeijer marked this conversation as resolved.
Show resolved Hide resolved
};

export default useSignedUrl;
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;
2 changes: 1 addition & 1 deletion src/providers/QueryProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from 'react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 3600,
staleTime: 60 * 60 * 1000,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch with the missing ms multiplier. Do you think an hour is a good default though or potentially long for all but the most basic config values?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, the default staleTime is 5 minutes which is a bit short in my opinion. The data doesn't change that often.

It does provide a more pleasant browsing experience, especially with URL signing (DRM) since all requests take longer.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought the signed urls / drm urls only worked for a short time

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The video DRM tokens are, but that doesn't count for the media/playlist metadata requests as long it's cached. We still do need to invalidate the token after 15 minutes so that we don't risk using invalidated tokens.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, so the strategy is to use an hour for the default for things that can be cached indefinitely, and then control invalidation for other things manually?

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 @@ -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';
Expand All @@ -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;
Expand All @@ -52,7 +52,7 @@ const Movie = ({ match, location }: RouteComponentProps<MovieRouteParams>): 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));

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
Loading