Skip to content

Commit

Permalink
Merge branch 'release-candidate' into release
Browse files Browse the repository at this point in the history
  • Loading branch information
AntonLantukh committed Jun 24, 2024
2 parents e4c4bd0 + 5958bdb commit 5ba16d3
Show file tree
Hide file tree
Showing 23 changed files with 294 additions and 160 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## [6.3.0](https://github.com/jwplayer/ott-web-app/compare/v6.2.0...v6.3.0) (2024-06-24)


### Features

* **project:** add content lists (recommendations) ([#556](https://github.com/jwplayer/ott-web-app/issues/556)) ([790932b](https://github.com/jwplayer/ott-web-app/commit/790932b62471135d8e037d8e027717377016c131))

## [6.2.0](https://github.com/jwplayer/ott-web-app/compare/v6.1.2...v6.2.0) (2024-06-13)


Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@jwp/ott",
"version": "6.2.0",
"version": "6.3.0",
"private": true,
"license": "Apache-2.0",
"repository": "https://github.com/jwplayer/ott-web-app.git",
Expand Down
19 changes: 15 additions & 4 deletions packages/common/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,12 @@ export const ADYEN_LIVE_CLIENT_KEY = 'live_BQDOFBYTGZB3XKF62GBYSLPUJ4YW2TPL';
// how often the live channel schedule is refetched in ms
export const LIVE_CHANNELS_REFETCH_INTERVAL = 15 * 60_000;

// Some predefined types of JW
export const CONTENT_TYPE = {
// Some predefined media types of JW
export const MEDIA_CONTENT_TYPE = {
// Series page with seasons / episodes
series: 'series',
// Separate episode page
episode: 'episode',
// Page with a list of live channels
live: 'live',
// Live channel (24x7)
liveChannel: 'livechannel',
// Temporary live stream that starts at a specific time
Expand All @@ -48,6 +46,12 @@ export const CONTENT_TYPE = {
hub: 'hub',
} as const;

// Some predefined playlist types of JW
export const PLAYLIST_CONTENT_TYPE = {
// Page with a list of live channels
live: 'live',
} as const;

// OTT shared player
export const OTT_GLOBAL_PLAYER_ID = 'M4qoGvUk';

Expand Down Expand Up @@ -81,3 +85,10 @@ export const EPG_TYPE = {
jwp: 'jwp',
viewNexa: 'viewnexa',
} as const;

export const PLAYLIST_TYPE = {
playlist: 'playlist',
continue_watching: 'continue_watching',
favorites: 'favorites',
content_list: 'content_list',
} as const;
1 change: 1 addition & 0 deletions packages/common/src/paths.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const PATH_HOME = '/';

export const PATH_MEDIA = '/m/:id/*';
export const PATH_PLAYLIST = '/p/:id/*';
export const PATH_CONTENT_LIST = '/n/:id/*';
export const PATH_LEGACY_SERIES = '/s/:id/*';

export const PATH_SEARCH = '/q/*';
Expand Down
120 changes: 84 additions & 36 deletions packages/common/src/services/ApiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getDataOrThrow } from '../utils/api';
import { filterMediaOffers } from '../utils/entitlements';
import { useConfigStore as ConfigStore } from '../stores/ConfigStore';
import type { GetPlaylistParams, Playlist, PlaylistItem } from '../../types/playlist';
import type { ContentList, GetContentSearchParams } from '../../types/content-list';
import type { AdSchedule } from '../../types/ad-schedule';
import type { EpisodeInSeries, EpisodesRes, EpisodesWithPagination, GetSeriesParams, Series } from '../../types/series';
import env from '../env';
Expand All @@ -26,8 +27,8 @@ export default class ApiService {
* We use playlistLabel prop to define the label used for all media items inside.
* That way we can change the behavior of the same media items being in different playlists
*/
private generateAlternateImageURL = ({ item, label, playlistLabel }: { item: PlaylistItem; label: string; playlistLabel?: string }) => {
const pathname = `/v2/media/${item.mediaid}/images/${playlistLabel || label}.webp`;
private generateAlternateImageURL = ({ mediaId, label, playlistLabel }: { mediaId: string; label: string; playlistLabel?: string }) => {
const pathname = `/v2/media/${mediaId}/images/${playlistLabel || label}.webp`;
const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, { poster_fallback: 1, fallback: playlistLabel ? label : null });

return url;
Expand All @@ -44,20 +45,66 @@ export default class ApiService {
return date ? parseISO(date) : undefined;
};

/**
* Transform incoming content lists
*/
private transformContentList = (contentList: ContentList): Playlist => {
const { list, ...rest } = contentList;

const playlist: Playlist = { ...rest, playlist: [] };

playlist.playlist = list.map((item) => {
const { custom_params, media_id, description, tags, ...rest } = item;

const playlistItem: PlaylistItem = {
feedid: contentList.id,
mediaid: media_id,
tags: tags.join(','),
description: description || '',
sources: [],
images: [],
image: '',
link: '',
pubdate: 0,
...rest,
...custom_params,
};

return this.transformMediaItem(playlistItem, playlist);
});

return playlist;
};

/**
* Transform incoming playlists
*/
private transformPlaylist = (playlist: Playlist, relatedMediaId?: string) => {
playlist.playlist = playlist.playlist.map((item) => this.transformMediaItem(item, playlist));

// remove the related media item (when this is a recommendations playlist)
if (relatedMediaId) {
playlist.playlist = playlist.playlist.filter((item) => item.mediaid !== relatedMediaId);
}

return playlist;
};

/**
* Transform incoming media items
* - Parses productId into MediaOffer[] for all cleeng offers
*/
private transformMediaItem = (item: PlaylistItem, playlist?: Playlist) => {
transformMediaItem = (item: PlaylistItem, playlist?: Playlist) => {
const config = ConfigStore.getState().config;
const offerKeys = Object.keys(config?.integrations)[0];
const playlistLabel = playlist?.imageLabel;
const mediaId = item.mediaid;

const transformedMediaItem = {
...item,
cardImage: this.generateAlternateImageURL({ item, label: ImageProperty.CARD, playlistLabel }),
channelLogoImage: this.generateAlternateImageURL({ item, label: ImageProperty.CHANNEL_LOGO, playlistLabel }),
backgroundImage: this.generateAlternateImageURL({ item, label: ImageProperty.BACKGROUND }),
cardImage: this.generateAlternateImageURL({ mediaId, label: ImageProperty.CARD, playlistLabel }),
channelLogoImage: this.generateAlternateImageURL({ mediaId, label: ImageProperty.CHANNEL_LOGO, playlistLabel }),
backgroundImage: this.generateAlternateImageURL({ mediaId, label: ImageProperty.BACKGROUND }),
mediaOffers: item.productIds ? filterMediaOffers(offerKeys, item.productIds) : undefined,
scheduledStart: this.parseDate(item, 'VCH.ScheduledStart'),
scheduledEnd: this.parseDate(item, 'VCH.ScheduledEnd'),
Expand All @@ -69,18 +116,6 @@ export default class ApiService {
return transformedMediaItem;
};

/**
* Transform incoming playlists
*/
private transformPlaylist = (playlist: Playlist, relatedMediaId?: string) => {
playlist.playlist = playlist.playlist.map((item) => this.transformMediaItem(item, playlist));

// remove the related media item (when this is a recommendations playlist)
if (relatedMediaId) playlist.playlist.filter((item) => item.mediaid !== relatedMediaId);

return playlist;
};

private transformEpisodes = (episodesRes: EpisodesRes, seasonNumber?: number) => {
const { episodes, page, page_limit, total } = episodesRes;

Expand All @@ -97,22 +132,6 @@ export default class ApiService {
};
};

/**
* Get playlist by id
*/
getPlaylistById = async (id?: string, params: GetPlaylistParams = {}): Promise<Playlist | undefined> => {
if (!id) {
return undefined;
}

const pathname = `/v2/playlists/${id}`;
const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, params);
const response = await fetch(url);
const data = (await getDataOrThrow(response)) as Playlist;

return this.transformPlaylist(data, params.related_media_id);
};

/**
* Get watchlist by playlistId
*/
Expand Down Expand Up @@ -246,11 +265,40 @@ export default class ApiService {
return (await getDataOrThrow(response)) as AdSchedule;
};

getAppContentSearch = async (siteId: string, searchQuery: string | undefined) => {
/**
* Get playlist by id
*/
getPlaylistById = async (id?: string, params: GetPlaylistParams = {}): Promise<Playlist | undefined> => {
if (!id) {
return undefined;
}

const pathname = `/v2/playlists/${id}`;
const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, params);
const response = await fetch(url);
const data = (await getDataOrThrow(response)) as Playlist;

return this.transformPlaylist(data, params.related_media_id);
};

getContentList = async ({ id, siteId }: { id: string | undefined; siteId: string }): Promise<Playlist | undefined> => {
if (!id || !siteId) {
throw new Error('List ID and Site ID are required');
}

const pathname = `/v2/sites/${siteId}/content_lists/${id}`;
const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, {});
const response = await fetch(url);
const data = (await getDataOrThrow(response)) as ContentList;

return this.transformContentList(data);
};

getContentSearch = async ({ siteId, params }: { siteId: string; params: GetContentSearchParams }) => {
const pathname = `/v2/sites/${siteId}/app_content/media/search`;

const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, {
search_query: searchQuery,
search_query: params.searchTerm,
});

const response = await fetch(url);
Expand Down
4 changes: 2 additions & 2 deletions packages/common/src/utils/configSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ const contentSchema: SchemaOf<Content> = object({
title: string().notRequired(),
featured: boolean().notRequired(),
backgroundColor: string().nullable().notRequired(),
type: mixed().oneOf(['playlist', 'continue_watching', 'favorites']),
type: mixed().oneOf(['playlist', 'continue_watching', 'favorites', 'content_list']),
}).defined();

const menuSchema: SchemaOf<Menu> = object().shape({
label: string().defined(),
contentId: string().defined(),
filterTags: string().notRequired(),
type: mixed().oneOf(['playlist']).notRequired(),
type: mixed().oneOf(['playlist', 'content_list']).notRequired(),
});

const featuresSchema: SchemaOf<Features> = object({
Expand Down
4 changes: 2 additions & 2 deletions packages/common/src/utils/liveEvent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { PlaylistItem } from '../../types/playlist';
import { CONTENT_TYPE } from '../constants';
import { MEDIA_CONTENT_TYPE } from '../constants';

import { isContentType } from './common';

Expand All @@ -18,7 +18,7 @@ export const enum MediaStatus {
}

export const isLiveEvent = (playlistItem?: PlaylistItem) => {
return !!playlistItem && isContentType(playlistItem, CONTENT_TYPE.liveEvent);
return !!playlistItem && isContentType(playlistItem, MEDIA_CONTENT_TYPE.liveEvent);
};

export const isScheduledOrLiveMedia = (playlistItem: PlaylistItem) => {
Expand Down
9 changes: 5 additions & 4 deletions packages/common/src/utils/media.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { PlaylistItem } from '../../types/playlist';
import { CONTENT_TYPE } from '../constants';
import { MEDIA_CONTENT_TYPE } from '../constants';

import { isContentType } from './common';

Expand All @@ -21,12 +21,12 @@ export const isLegacySeriesFlow = (item: PlaylistItem) => {

// For the new series flow we use contentType custom param to define media item to be series
// In this case media item and series item have the same id
export const isSeriesContentType = (item: PlaylistItem) => isContentType(item, CONTENT_TYPE.series);
export const isSeriesContentType = (item: PlaylistItem) => isContentType(item, MEDIA_CONTENT_TYPE.series);

export const isSeries = (item: PlaylistItem) => isLegacySeriesFlow(item) || isSeriesContentType(item);

export const isEpisode = (item: PlaylistItem) => {
return typeof item?.episodeNumber !== 'undefined' || isContentType(item, CONTENT_TYPE.episode);
return typeof item?.episodeNumber !== 'undefined' || isContentType(item, MEDIA_CONTENT_TYPE.episode);
};

export const getLegacySeriesPlaylistIdFromEpisodeTags = (item: PlaylistItem | undefined) => {
Expand All @@ -46,4 +46,5 @@ export const getLegacySeriesPlaylistIdFromEpisodeTags = (item: PlaylistItem | un
return;
};

export const isLiveChannel = (item: PlaylistItem): item is RequiredProperties<PlaylistItem, 'contentType'> => isContentType(item, CONTENT_TYPE.liveChannel);
export const isLiveChannel = (item: PlaylistItem): item is RequiredProperties<PlaylistItem, 'contentType'> =>
isContentType(item, MEDIA_CONTENT_TYPE.liveChannel);
6 changes: 5 additions & 1 deletion packages/common/src/utils/urlFormatting.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { PlaylistItem } from '../../types/playlist';
import { RELATIVE_PATH_USER_MY_PROFILE, PATH_MEDIA, PATH_PLAYLIST, PATH_USER_MY_PROFILE } from '../paths';
import { RELATIVE_PATH_USER_MY_PROFILE, PATH_MEDIA, PATH_PLAYLIST, PATH_USER_MY_PROFILE, PATH_CONTENT_LIST } from '../paths';

import { getLegacySeriesPlaylistIdFromEpisodeTags, getSeriesPlaylistIdFromCustomParams } from './media';

Expand Down Expand Up @@ -107,6 +107,10 @@ export const playlistURL = (id: string, title?: string) => {
return createPath(PATH_PLAYLIST, { id, title: title ? slugify(title) : undefined });
};

export const contentListURL = (id: string, title?: string) => {
return createPath(PATH_CONTENT_LIST, { id, title: title ? slugify(title) : undefined });
};

export const liveChannelsURL = (playlistId: string, channelId?: string, play = false) => {
return createPath(
PATH_PLAYLIST,
Expand Down
10 changes: 7 additions & 3 deletions packages/common/types/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { PLAYLIST_TYPE } from '../src/constants';

import type { AdScheduleUrls } from './ad-schedule';

/**
Expand Down Expand Up @@ -39,20 +41,22 @@ export type Drm = {
defaultPolicyId: string;
};

export type ContentType = 'playlist' | 'continue_watching' | 'favorites';
export type PlaylistType = keyof typeof PLAYLIST_TYPE;

export type PlaylistMenuType = Extract<PlaylistType, 'playlist' | 'content_list'>;

export type Content = {
contentId?: string;
title?: string;
type: ContentType;
type: PlaylistType;
featured?: boolean;
backgroundColor?: string | null;
};

export type Menu = {
label: string;
contentId: string;
type?: Extract<ContentType, 'playlist'>;
type?: PlaylistMenuType;
filterTags?: string;
};

Expand Down
19 changes: 19 additions & 0 deletions packages/common/types/content-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { CustomParams } from './custom-params';

export type ContentListItem = {
media_id: string;
title: string;
description: string | null;
tags: string[];
duration: number;
custom_params: CustomParams;
};

export type ContentList = {
id: string;
title: string;
description: string | undefined;
list: ContentListItem[];
};

export type GetContentSearchParams = { searchTerm: string };
Loading

0 comments on commit 5ba16d3

Please sign in to comment.