Skip to content

Commit

Permalink
feat(project): add new way of handling media with passport
Browse files Browse the repository at this point in the history
  • Loading branch information
kiremitrov123 committed Sep 19, 2024
1 parent 138709f commit b081d51
Show file tree
Hide file tree
Showing 15 changed files with 185 additions and 16 deletions.
34 changes: 33 additions & 1 deletion packages/common/src/controllers/AccessController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import { INTEGRATION_TYPE } from '../modules/types';
import { getNamedModule } from '../modules/container';
import StorageService from '../services/StorageService';
import type { AccessTokens } from '../../types/access';
import { useAccessStore } from '../stores/AccessStore';
import JWPEntitlementService from '../services/JWPEntitlementService';

const ACCESS_TOKENS = 'access_tokens';

@injectable()
export default class AccessController {
private readonly entitlementService: JWPEntitlementService;
private readonly accessService: AccessService;
private readonly accountService: AccountService;
private readonly storageService: StorageService;
Expand All @@ -21,9 +24,11 @@ export default class AccessController {

constructor(
@inject(INTEGRATION_TYPE) integrationType: IntegrationType,
@inject(JWPEntitlementService) entitlementService: JWPEntitlementService,
@inject(StorageService) storageService: StorageService,
@inject(AccessService) accessService: AccessService,
) {
this.entitlementService = entitlementService;
this.accessService = accessService;
this.storageService = storageService;
this.accountService = getNamedModule(AccountService, integrationType);
Expand All @@ -41,6 +46,25 @@ export default class AccessController {
// Not awaiting to avoid blocking the loading process,
// as the access tokens can be stored asynchronously without affecting the app's performance
void this.generateOrRefreshAccessTokens();

// Fetches the entitled plans for the viewer and stores them into the access store.
await this.fetchAndStoreEntitledPlans();
};

getMediaById = async () => {};

fetchAndStoreEntitledPlans = async () => {
if (!this.siteId) {
return;
}
// Note: Without a valid plan ID, requests for media metadata cannot be made.
// TODO: Support for multiple plans should be added. Revisit this logic once the dependency on plan_id is changed.
const response = await this.entitlementService.getEntitledPlans({ siteId: this.siteId });
if (response?.plans?.length) {
// Find the SVOD plan or fallback to the first available plan
const entitledPlan = response.plans.find((plan) => plan.metadata.access_model === 'svod') || response.plans[0];
useAccessStore.setState({ entitledPlan });
}
};

generateOrRefreshAccessTokens = async () => {
Expand Down Expand Up @@ -85,17 +109,25 @@ export default class AccessController {
};

setAccessTokens = async (accessTokens: AccessTokens) => {
useAccessStore.setState({ passport: accessTokens.passport });

// Since the actual valid time for a passport token is 1 hour, set the expires to one hour from now.
// The expires field here is used as a helper to manage the passport's validity and refresh process.
const expires = new Date(Date.now() + 3600 * 1000).getTime();
return await this.storageService.setItem(ACCESS_TOKENS, JSON.stringify({ ...accessTokens, expires }), true);
};

getAccessTokens = async (): Promise<(AccessTokens & { expires: number }) | null> => {
return await this.storageService.getItem(ACCESS_TOKENS, true, true);
const accessTokens = await this.storageService.getItem<AccessTokens & { expires: number }>(ACCESS_TOKENS, true, true);
if (accessTokens) {
useAccessStore.setState({ passport: accessTokens.passport });
}

return accessTokens;
};

removeAccessTokens = async () => {
useAccessStore.setState({ passport: null });
return await this.storageService.removeItem(ACCESS_TOKENS);
};
}
1 change: 1 addition & 0 deletions packages/common/src/controllers/AccountController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export default class AccountController {

if (response) {
void this.accessController?.generateAccessTokens();
void this.accessController?.fetchAndStoreEntitledPlans();
await this.afterLogin(response.user, response.customerConsents);
return;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/common/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type Env = {

const env: Env = {
APP_VERSION: '',
APP_API_BASE_URL: 'https://cdn-dev.jwplayer.com',
APP_API_BASE_URL: 'https://cdn.jwplayer.com',
APP_API_ACCESS_BRIDGE_URL: '',
APP_PLAYER_ID: 'M4qoGvUk',
APP_FOOTER_TEXT: '',
Expand Down
19 changes: 19 additions & 0 deletions packages/common/src/services/ApiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,25 @@ export default class ApiService {
return this.transformMediaItem(mediaItem);
};

/**
* Get media by id
* @param {string} mediaId
* @param {string} siteId
* @param {string} planId
* @param {string} passport
*/
getMediaByIdWithPassport = async (mediaId: string, siteId: string, planId: string, passport: string): Promise<PlaylistItem | undefined> => {
const pathname = `/v2/sites/${siteId}/media/${mediaId}/playback.json`;
const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, { passport, plan_id: planId });
const response = await fetch(url);
const data = (await getDataOrThrow(response)) as Playlist;
const mediaItem = data.playlist[0];

if (!mediaItem) throw new Error('MediaItem not found');

return this.transformMediaItem(mediaItem);
};

/**
* Get series by id
* @param {string} id
Expand Down
2 changes: 1 addition & 1 deletion packages/common/src/services/JWPEntitlementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default class JWPEntitlementService {
});
return data;
} catch {
throw new Error('Failed to get entitled plans');
throw new Error('Failed to fetch entitled plans');
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default class JWPAPIService {
this.useSandboxEnv = useSandboxEnv;
};

private getBaseUrl = () => (this.useSandboxEnv ? 'https://daily-sims.jwplayer.com' : 'https://sims.jwplayer.com');
private getBaseUrl = () => (this.useSandboxEnv ? 'https://staging-sims.jwplayer.com' : 'https://sims.jwplayer.com');

setToken = (token: string, refreshToken = '', expires: number) => {
return this.storageService.setItem(INPLAYER_TOKEN_KEY, JSON.stringify({ token, refreshToken, expires }), false);
Expand Down
13 changes: 13 additions & 0 deletions packages/common/src/stores/AccessStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Plan } from '../../types/plans';

import { createStore } from './utils';

type AccessStore = {
passport: string | null;
entitledPlan: Plan | null;
};

export const useAccessStore = createStore<AccessStore>('AccessStore', () => ({
passport: null,
entitledPlan: null,
}));
43 changes: 41 additions & 2 deletions packages/common/src/utils/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,19 @@ const isBCLManifestType = (sourceUrl: string, baseUrl: string, mediaId: string,
return extensions.some((ext) => sourceUrl === `${baseUrl}/live/broadcast/${mediaId}.${ext}`);
};

export const getSources = ({ item, baseUrl, config, user }: { item: PlaylistItem; baseUrl: string; config: Config; user: Customer | null }) => {
export const getSources = ({
item,
baseUrl,
config,
user,
passport,
}: {
item: PlaylistItem;
baseUrl: string;
config: Config;
user: Customer | null;
passport: string | null;
}) => {
const { sources, mediaid } = item;
const { adConfig, siteId, adDeliveryMethod } = config;

Expand All @@ -34,10 +46,37 @@ export const getSources = ({ item, baseUrl, config, user }: { item: PlaylistItem
// Attach user_id only for VOD and BCL SaaS Live Streams (doesn't work with SSAI items)
} else if ((isVODManifest || isBCLManifest) && userId) {
url.searchParams.set('user_id', userId);
} else if (passport) {
// Attach the passport in all the drm sources as it's needed for the licence request.
// Passport is only available if Access Bridge is in use.
attachPassportToSourceWithDRM(source, passport);
}

source.file = url.toString();

return source;
});
};

function attachPassportToSourceWithDRM(source: Source, passport: string): Source {
function updateUrl(urlString: string, passport: string): string {
const url = new URL(urlString);
if (!url.searchParams.has('token')) {
url.searchParams.set('passport', passport);
}
return url.toString();
}

if (source?.drm) {
if (source.drm?.playready?.url) {
source.drm.playready.url = updateUrl(source.drm.playready.url, passport);
}
if (source.drm?.widevine?.url) {
source.drm.widevine.url = updateUrl(source.drm.widevine.url, passport);
}
if (source.drm?.fairplay?.processSpcUrl) {
source.drm.fairplay.processSpcUrl = updateUrl(source.drm.fairplay.processSpcUrl, passport);
}
}

return source;
}
10 changes: 6 additions & 4 deletions packages/common/types/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ export type AccessControlPlan = {
};

export type Plan = {
name: string;
access_model: 'free' | 'freeauth' | 'svod';
access_plan: AccessControlPlan;
access: AccessOptions;
id: string;
original_id: number;
exp: number;
metadata: {
name: string;
access: AccessOptions;
access_model: 'free' | 'freeauth' | 'svod';
external_providers: PlanExternalProviders;
};
};
Expand Down
13 changes: 13 additions & 0 deletions packages/common/types/playlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,22 @@ export type Image = {
width: number;
};

export type DRM = {
playready?: {
url: string;
};
widevine?: {
url: string;
};
fairplay?: {
processSpcUrl: string;
};
};

export type Source = {
file: string;
type: string;
drm?: DRM;
};

export type Track = {
Expand Down
13 changes: 11 additions & 2 deletions packages/hooks-react/src/useContentProtection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,28 @@ const useContentProtection = <T>(
const genericEntitlementService = getModule(GenericEntitlementService);
const jwpEntitlementService = getModule(JWPEntitlementService);

const { configId, signingConfig, contentProtection, jwp, urlSigning } = useConfigStore(({ config }) => ({
const { configId, signingConfig, contentProtection, jwp, urlSigning, isAcessBridgeEnabled } = useConfigStore(({ config, settings }) => ({
configId: config.id,
signingConfig: config.contentSigningService,
contentProtection: config.contentProtection,
jwp: config.integrations.jwp,
urlSigning: isTruthyCustomParamValue(config?.custom?.urlSigning),
isAcessBridgeEnabled: !!settings?.apiAccessBridgeUrl,
}));

const host = signingConfig?.host;
const drmPolicyId = contentProtection?.drm?.defaultPolicyId ?? signingConfig?.drmPolicyId;
const signingEnabled = !!urlSigning || !!host || (!!drmPolicyId && !host);

const { data: token, isLoading } = useQuery(
['token', type, id, params],
async () => {
if (isAcessBridgeEnabled) {
// if access control is set up we return nothing
// the useProtectedMedia hook will take care of it
return;
}

// if provider is not JWP
if (!!id && !!host) {
const accountController = getModule(AccountController);
Expand All @@ -42,12 +50,13 @@ const useContentProtection = <T>(

return genericEntitlementService.getMediaToken(host, id, authData?.jwt, params, drmPolicyId);
}

// if provider is JWP
if (jwp && configId && !!id && signingEnabled) {
return jwpEntitlementService.getJWPMediaToken(configId, id);
}
},
{ enabled: signingEnabled && enabled && !!id, keepPreviousData: false, staleTime: 15 * 60 * 1000 },
{ enabled: (signingEnabled || isAcessBridgeEnabled) && enabled && !!id, keepPreviousData: false, staleTime: 15 * 60 * 1000 },
);

const queryResult = useQuery<T | undefined>([type, id, params, token], async () => callback(token, drmPolicyId), {
Expand Down
4 changes: 3 additions & 1 deletion packages/hooks-react/src/useMediaSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore';
import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore';
import type { PlaylistItem, Source } from '@jwp/ott-common/types/playlist';
import { getSources } from '@jwp/ott-common/src/utils/sources';
import { useAccessStore } from '@jwp/ott-common/src/stores/AccessStore';

/** Modify manifest URLs to handle server ads and analytics params */
export const useMediaSources = ({ item, baseUrl }: { item: PlaylistItem; baseUrl: string }): Source[] => {
const config = useConfigStore((s) => s.config);
const user = useAccountStore((s) => s.user);
const passport = useAccessStore((s) => s.passport);

return useMemo(() => getSources({ item, baseUrl, config, user }), [item, baseUrl, config, user]);
return useMemo(() => getSources({ item, baseUrl, config, user, passport }), [item, baseUrl, config, user, passport]);
};
39 changes: 38 additions & 1 deletion packages/hooks-react/src/useProtectedMedia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,49 @@ import { useQuery } from 'react-query';
import type { PlaylistItem } from '@jwp/ott-common/types/playlist';
import ApiService from '@jwp/ott-common/src/services/ApiService';
import { getModule } from '@jwp/ott-common/src/modules/container';
import { useAccessStore } from '@jwp/ott-common/src/stores/AccessStore';
import AccessController from '@jwp/ott-common/src/controllers/AccessController';
import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore';
import { ApiError } from '@jwp/ott-common/src/utils/api';

import useContentProtection from './useContentProtection';

export default function useProtectedMedia(item: PlaylistItem) {
const apiService = getModule(ApiService);
const contentProtectionQuery = useContentProtection('media', item.mediaid, (token, drmPolicyId) => apiService.getMediaById(item.mediaid, token, drmPolicyId));
const accessController = getModule(AccessController);

const { siteId } = useConfigStore().config;
const { passport, entitledPlan } = useAccessStore();

const getMedia = async (token?: string, drmPolicyId?: string) => {
// If nothing from Access Bridge is present, the flow remains as it was.
if (!passport || !entitledPlan) {
return apiService.getMediaById(item.mediaid, token, drmPolicyId);
}

// Otherwise use passport to get the media
// TODO: the logic needs to be revisited once the dependency on planId is changed.
try {
return await apiService.getMediaByIdWithPassport(item.mediaid, siteId, entitledPlan.id, passport);
} catch (error: unknown) {
if (error instanceof ApiError && error.code === 403) {
// If the passport is invalid or expired, refresh and get media again
await accessController.refreshAccessTokens();
const updatedPassport = useAccessStore.getState().passport;

if (updatedPassport) {
return await apiService.getMediaByIdWithPassport(item.mediaid, siteId, entitledPlan.id, updatedPassport);
}

throw new Error('Failed to refresh passport and retrieve media.');
}

throw error;
}
};

const contentProtectionQuery = useContentProtection('media', item.mediaid, async (token, drmPolicyId) => getMedia(token, drmPolicyId));

const { isLoading, data: isGeoBlocked } = useQuery(
['media', 'geo', item.mediaid],
() => {
Expand Down
2 changes: 1 addition & 1 deletion platforms/web/.env
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
APP_API_BASE_URL=https://cdn-dev.jwplayer.com
APP_API_BASE_URL=https://cdn.jwplayer.com
APP_PLAYER_ID=M4qoGvUk

# page metadata (SEO)
Expand Down
4 changes: 3 additions & 1 deletion platforms/web/ini/.webapp.dev.ini
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ defaultConfigSource = gnnuzabk
; When developing, switching between configs is useful for test and debug
UNSAFE_allowAnyConfigSource = true
; Access Bridge service API url host
apiAccessBridgeUrl=
apiAccessBridgeUrl =


0 comments on commit b081d51

Please sign in to comment.