From 9a71457ae51dec38538e5b5ac719426250d3cae4 Mon Sep 17 00:00:00 2001 From: Anton Lantukh Date: Mon, 20 Nov 2023 11:59:31 +0100 Subject: [PATCH 01/18] feat(project): add view nexa epg provider --- package.json | 1 + src/config.ts | 5 + src/hooks/useLiveChannels.test.ts | 6 +- src/hooks/useLiveChannels.ts | 4 +- src/modules/container.ts | 10 +- src/modules/register.ts | 17 +- src/services/epg.service.test.ts | 344 ------------ src/services/epg/epgClient.service.test.ts | 121 ++++ .../epgClient.service.ts} | 100 +--- src/services/epg/epgProvider.service.ts | 14 + src/services/epg/jwEpg.service.test.ts | 165 ++++++ src/services/epg/jwEpg.service.ts | 74 +++ src/services/epg/viewNexa.service.test.ts | 237 ++++++++ src/services/epg/viewNexaEpg.service.ts | 79 +++ src/utils/media.ts | 2 +- test/epg/jwChannel.json | 530 ++++++++++++++++++ test/epg/viewNexaChannel.xml | 91 +++ test/fixtures/livePlaylist.json | 8 +- types/playlist.d.ts | 1 + types/static.d.ts | 4 + vite.config.ts | 1 + yarn.lock | 12 + 22 files changed, 1389 insertions(+), 437 deletions(-) delete mode 100644 src/services/epg.service.test.ts create mode 100644 src/services/epg/epgClient.service.test.ts rename src/services/{epg.service.ts => epg/epgClient.service.ts} (54%) create mode 100644 src/services/epg/epgProvider.service.ts create mode 100644 src/services/epg/jwEpg.service.test.ts create mode 100644 src/services/epg/jwEpg.service.ts create mode 100644 src/services/epg/viewNexa.service.test.ts create mode 100644 src/services/epg/viewNexaEpg.service.ts create mode 100644 test/epg/jwChannel.json create mode 100644 test/epg/viewNexaChannel.xml diff --git a/package.json b/package.json index 8851047dc..d0742b5a9 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "classnames": "^2.3.1", "date-fns": "^2.28.0", "dompurify": "^2.3.8", + "fast-xml-parser": "^4.3.2", "i18next": "^22.4.15", "i18next-browser-languagedetector": "^6.1.1", "i18next-http-backend": "^2.2.0", diff --git a/src/config.ts b/src/config.ts index 0259d9a98..f83aa6cb5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -73,3 +73,8 @@ export const DEFAULT_FEATURES = { hasProfiles: false, hasNotifications: false, }; + +export const EPG_TYPE = { + JW: 'JW', + VIEW_NEXA: 'VIEW_NEXA', +} as const; diff --git a/src/hooks/useLiveChannels.test.ts b/src/hooks/useLiveChannels.test.ts index 5e3a6fd53..c33e00821 100644 --- a/src/hooks/useLiveChannels.test.ts +++ b/src/hooks/useLiveChannels.test.ts @@ -8,7 +8,7 @@ import type { Playlist } from '#types/playlist'; import livePlaylistFixture from '#test/fixtures/livePlaylist.json'; import epgChannelsFixture from '#test/fixtures/epgChannels.json'; import epgChannelsUpdateFixture from '#test/fixtures/epgChannelsUpdate.json'; -import EpgService from '#src/services/epg.service'; +import EpgClientService from '#src/services/epg/epgClient.service'; const livePlaylist: Playlist = livePlaylistFixture; const schedule: EpgChannel[] = epgChannelsFixture; @@ -17,9 +17,9 @@ const scheduleUpdate: EpgChannel[] = epgChannelsUpdateFixture; const mockSchedule = vi.fn(); vi.mock('#src/modules/container', () => ({ - getModule: (type: typeof EpgService) => { + getModule: (type: typeof EpgClientService) => { switch (type) { - case EpgService: + case EpgClientService: return { getSchedules: mockSchedule }; } }, diff --git a/src/hooks/useLiveChannels.ts b/src/hooks/useLiveChannels.ts index 387aaf439..cc0776839 100644 --- a/src/hooks/useLiveChannels.ts +++ b/src/hooks/useLiveChannels.ts @@ -5,8 +5,8 @@ import type { PlaylistItem } from '#types/playlist'; import type { EpgProgram, EpgChannel } from '#types/epg'; import { getLiveProgram, programIsLive } from '#src/utils/epg'; import { LIVE_CHANNELS_REFETCH_INTERVAL } from '#src/config'; -import EpgService from '#src/services/epg.service'; import { getModule } from '#src/modules/container'; +import EpgClientService from '#src/services/epg/epgClient.service'; /** * This hook fetches the schedules for the given list of playlist items and manages the current channel and program. @@ -31,7 +31,7 @@ const useLiveChannels = ({ initialChannelId: string | undefined; enableAutoUpdate?: boolean; }) => { - const epgService = getModule(EpgService); + const epgService = getModule(EpgClientService); const { data: channels = [] } = useQuery(['schedules', ...playlist.map(({ mediaid }) => mediaid)], () => epgService.getSchedules(playlist), { refetchInterval: LIVE_CHANNELS_REFETCH_INTERVAL, diff --git a/src/modules/container.ts b/src/modules/container.ts index 1513fef69..14d4b5f41 100644 --- a/src/modules/container.ts +++ b/src/modules/container.ts @@ -1,7 +1,5 @@ import { Container, interfaces } from 'inversify'; -import type { IntegrationType } from '#types/Config'; - export const container = new Container({ defaultScope: 'Singleton', skipBaseClassChecks: true }); export function getModule(constructorFunction: interfaces.ServiceIdentifier, required: false): T | undefined; @@ -15,10 +13,10 @@ export function getModule(constructorFunction: interfaces.ServiceIdentifier(constructorFunction: interfaces.ServiceIdentifier, integration: IntegrationType | null, required: false): T | undefined; -export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, integration: IntegrationType | null, required: true): T; -export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, integration: IntegrationType | null): T; -export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, integration: IntegrationType | null, required = true): T | undefined { +export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, integration: string | null, required: false): T | undefined; +export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, integration: string | null, required: true): T; +export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, integration: string | null): T; +export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, integration: string | null, required = true): T | undefined { if (!integration) { return; } diff --git a/src/modules/register.ts b/src/modules/register.ts index f184aea0c..df2b8a612 100644 --- a/src/modules/register.ts +++ b/src/modules/register.ts @@ -1,18 +1,23 @@ // To organize imports in a better way /* eslint-disable import/order */ import 'reflect-metadata'; // include once in the app for inversify (see: https://github.com/inversify/InversifyJS/blob/master/README.md#-installation) -import { INTEGRATION } from '#src/config'; +import { EPG_TYPE, INTEGRATION } from '#src/config'; import { container } from '#src/modules/container'; import ApiService from '#src/services/api.service'; import WatchHistoryService from '#src/services/watchhistory.service'; -import EpgService from '#src/services/epg.service'; import GenericEntitlementService from '#src/services/genericEntitlement.service'; import JWPEntitlementService from '#src/services/jwpEntitlement.service'; import FavoritesService from '#src/services/favorites.service'; import ConfigService from '#src/services/config.service'; import SettingsService from '#src/services/settings.service'; +// Epg services +import EpgClientService from '#src/services/epg/epgClient.service'; +import EpgProvider from '#src/services/epg/epgProvider.service'; +import ViewNexaEpgService from '#src/services/epg/viewNexaEpg.service'; +import JWEpgService from '#src/services/epg/jwEpg.service'; + import WatchHistoryController from '#src/stores/WatchHistoryController'; import CheckoutController from '#src/stores/CheckoutController'; import AccountController from '#src/stores/AccountController'; @@ -40,7 +45,6 @@ import InplayerProfileService from '#src/services/inplayer.profile.service'; // Common services container.bind(ConfigService).toSelf(); -container.bind(EpgService).toSelf(); container.bind(WatchHistoryService).toSelf(); container.bind(FavoritesService).toSelf(); container.bind(GenericEntitlementService).toSelf(); @@ -52,7 +56,7 @@ container.bind(AppController).toSelf(); container.bind(WatchHistoryController).toSelf(); container.bind(FavoritesController).toSelf(); -// Integration controllers (conditionally register?) +// Integration controllers container.bind(AccountController).toSelf(); container.bind(CheckoutController).toSelf(); container.bind(ProfileController).toSelf(); @@ -73,3 +77,8 @@ container.bind(AccountService).to(InplayerAccountService).whenTargetNamed(INTEGR container.bind(CheckoutService).to(InplayerCheckoutService).whenTargetNamed(INTEGRATION.JWP); container.bind(SubscriptionService).to(InplayerSubscriptionService).whenTargetNamed(INTEGRATION.JWP); container.bind(ProfileService).to(InplayerProfileService).whenTargetNamed(INTEGRATION.JWP); + +// EPG integration +container.bind(EpgClientService).toSelf(); +container.bind(EpgProvider).to(JWEpgService).whenTargetNamed(EPG_TYPE.JW); +container.bind(EpgProvider).to(ViewNexaEpgService).whenTargetNamed(EPG_TYPE.VIEW_NEXA); diff --git a/src/services/epg.service.test.ts b/src/services/epg.service.test.ts deleted file mode 100644 index 6599d9b02..000000000 --- a/src/services/epg.service.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { afterEach, beforeEach, describe, expect } from 'vitest'; -import { mockFetch, mockGet } from 'vi-fetch'; -import { register, unregister } from 'timezone-mock'; - -import EpgService from './epg.service'; - -import type { EpgProgram } from '#types/epg'; -import scheduleFixture from '#test/fixtures/schedule.json'; -import livePlaylistFixture from '#test/fixtures/livePlaylist.json'; -import type { Playlist } from '#types/playlist'; - -const livePlaylist = livePlaylistFixture as Playlist; -const scheduleData = scheduleFixture as EpgProgram[]; -const epgService = new EpgService(); - -describe('epgService', () => { - beforeEach(() => { - mockFetch.clearAll(); - vi.useFakeTimers(); - }); - - afterEach(() => { - // must be called before `vi.useRealTimers()` - unregister(); - vi.restoreAllMocks(); - vi.useRealTimers(); - }); - - test('fetchSchedule performs a request', async () => { - const mock = mockGet('/epg/channel1.json').willResolve([]); - const data = await epgService.fetchSchedule(livePlaylist.playlist[0]); - - const request = mock.getRouteCalls()[0]; - const requestHeaders = request?.[1]?.headers; - - expect(data).toEqual([]); - expect(mock).toHaveFetched(); - expect(requestHeaders).toEqual(new Headers()); // no headers expected - }); - - test('fetchSchedule adds authentication token', async () => { - const mock = mockGet('/epg/channel1.json').willResolve([]); - const item = Object.assign({}, livePlaylist.playlist[0]); - - item.scheduleToken = 'AUTH-TOKEN'; - const data = await epgService.fetchSchedule(item); - - const request = mock.getRouteCalls()[0]; - const requestHeaders = request?.[1]?.headers; - - expect(data).toEqual([]); - expect(mock).toHaveFetched(); - expect(requestHeaders).toEqual(new Headers({ 'API-KEY': 'AUTH-TOKEN' })); - }); - - test('getSchedule fetches and validates a valid schedule', async () => { - const mock = mockGet('/epg/channel1.json').willResolve(scheduleData); - const schedule = await epgService.getSchedule(livePlaylist.playlist[0]); - - expect(mock).toHaveFetched(); - expect(schedule.title).toEqual('Channel 1'); - expect(schedule.programs.length).toEqual(14); - expect(schedule.catchupHours).toEqual(7); - }); - - test('getSchedule enables the demo transformer when scheduleDemo is set', async () => { - const mock = mockGet('/epg/channel1.json').willResolve(scheduleData); - - // mock the date - vi.setSystemTime(new Date(2036, 5, 3, 14, 30, 10, 500)); - - const item = Object.assign({}, livePlaylist.playlist[0]); - item.scheduleDemo = '1'; - - const schedule = await epgService.getSchedule(item); - - expect(mock).toHaveFetched(); - expect(schedule.title).toEqual('Channel 1'); - // first program - expect(schedule.programs[0].startTime).toEqual('2036-06-03T23:50:00.000Z'); - expect(schedule.programs[0].endTime).toEqual('2036-06-04T00:55:00.000Z'); - - // last program - expect(schedule.programs[13].startTime).toEqual('2036-06-04T07:00:00.000Z'); - expect(schedule.programs[13].endTime).toEqual('2036-06-04T07:40:00.000Z'); - }); - - test('getSchedules fetches and validates multiple schedules', async () => { - const channel1Mock = mockGet('/epg/channel1.json').willResolve(scheduleData); - const channel2Mock = mockGet('/epg/channel2.json').willResolve([]); - const channel3Mock = mockGet('/epg/does-not-exist.json').willFail('', 404, 'Not found'); - const channel4Mock = mockGet('/epg/network-error.json').willThrow(new Error('Network error')); - - // getSchedules for multiple playlist items - const schedules = await epgService.getSchedules(livePlaylist.playlist); - - // make sure we are testing a playlist with four media items - expect(livePlaylistFixture.playlist.length).toBe(4); - - // all channels have fetched - expect(channel1Mock).toHaveFetchedTimes(1); - expect(channel2Mock).toHaveFetchedTimes(1); - expect(channel3Mock).toHaveFetchedTimes(1); - expect(channel4Mock).toHaveFetchedTimes(1); - - // valid schedule with 10 programs - expect(schedules[0].title).toEqual('Channel 1'); - expect(schedules[0].programs.length).toEqual(14); - - // empty schedule - expect(schedules[1].title).toEqual('Channel 2'); - expect(schedules[1].programs.length).toEqual(0); - - // empty schedule (failed fetching) - expect(schedules[2].title).toEqual('Channel 3'); - expect(schedules[2].programs.length).toEqual(0); - - // empty schedule (network error) - expect(schedules[3].title).toEqual('Channel 4'); - expect(schedules[3].programs.length).toEqual(0); - }); - - test('parseSchedule should remove programs where required fields are missing', async () => { - // missing title - const schedule1 = await epgService.parseSchedule([ - { - id: '1234-1234-1234-1234-1234', - startTime: '2022-07-19T09:00:00Z', - endTime: '2022-07-19T12:00:00Z', - }, - ]); - // missing startTime - const schedule2 = await epgService.parseSchedule([ - { - id: '1234-1234-1234-1234-1234', - title: 'The title', - endTime: '2022-07-19T12:00:00Z', - }, - ]); - // missing endTime - const schedule3 = await epgService.parseSchedule([ - { - id: '1234-1234-1234-1234-1234', - title: 'The title', - startTime: '2022-07-19T09:00:00Z', - }, - ]); - // missing id - const schedule4 = await epgService.parseSchedule([ - { - title: 'The title', - startTime: '2022-07-19T09:00:00Z', - endTime: '2022-07-19T12:00:00Z', - }, - ]); - - expect(schedule1.length).toEqual(0); - expect(schedule2.length).toEqual(0); - expect(schedule3.length).toEqual(0); - expect(schedule4.length).toEqual(0); - }); - - test('parseSchedule should remove programs where the startTime or endTime are not valid ISO8601', async () => { - // invalid startTime - const schedule1 = await epgService.parseSchedule([ - { - id: '1234-1234-1234-1234-1234', - title: 'Test item', - startTime: 'this is not ISO8601', - endTime: '2022-07-19T12:00:00Z', - }, - ]); - // invalid endTime - const schedule2 = await epgService.parseSchedule([ - { - id: '1234-1234-1234-1234-1234', - title: 'The title', - startTime: '2022-07-19T09:00:00Z', - endTime: 'this is not ISO8601', - }, - ]); - - expect(schedule1.length).toEqual(0); - expect(schedule2.length).toEqual(0); - }); - - test('parseSchedule should update the start and end time when demo is enabled', async () => { - // some date in the far future - vi.setSystemTime(new Date(2036, 5, 3, 14, 30, 10, 500)); - - const schedule = await epgService.parseSchedule( - [ - { - id: '1234-1234-1234-1234-1234', - title: 'Test item 1', - startTime: '2022-07-19T12:00:00Z', - endTime: '2022-07-19T17:00:00Z', - }, - { - id: '1234-1234-1234-1234-1234', - title: 'Test item 2', - startTime: '2022-07-19T17:00:00Z', - endTime: '2022-07-19T23:00:00Z', - }, - { - id: '1234-1234-1234-1234-1234', - title: 'Test item 3', - startTime: '2022-07-19T23:00:00Z', - endTime: '2022-07-20T05:30:00Z', - }, - { - id: '1234-1234-1234-1234-1234', - title: 'Test item 4', - startTime: '2022-07-20T05:30:00Z', - endTime: '2022-07-20T12:00:00Z', - }, - ], - true, - ); - - expect(schedule.length).toEqual(4); - - expect(schedule[0].startTime).toEqual('2036-06-03T12:00:00.000Z'); - expect(schedule[0].endTime).toEqual('2036-06-03T17:00:00.000Z'); - - expect(schedule[1].startTime).toEqual('2036-06-03T17:00:00.000Z'); - expect(schedule[1].endTime).toEqual('2036-06-03T23:00:00.000Z'); - - expect(schedule[2].startTime).toEqual('2036-06-03T23:00:00.000Z'); - expect(schedule[2].endTime).toEqual('2036-06-04T05:30:00.000Z'); - - expect(schedule[3].startTime).toEqual('2036-06-04T05:30:00.000Z'); - expect(schedule[3].endTime).toEqual('2036-06-04T12:00:00.000Z'); - }); - - test('parseSchedule should use the correct demo dates in different timezones', async () => { - // some date in the far future - vi.setSystemTime(new Date(2036, 5, 3, 1, 30, 10, 500)); - - register('Australia/Adelaide'); - - const schedule = await epgService.parseSchedule( - [ - { - id: '1234-1234-1234-1234-1234', - title: 'Test item 1', - startTime: '2022-07-19T22:00:00Z', - endTime: '2022-07-19T23:30:00Z', - }, - ], - true, - ); - - expect(schedule.length).toEqual(1); - expect(schedule[0].startTime).toEqual('2036-06-03T22:00:00.000Z'); - expect(schedule[0].endTime).toEqual('2036-06-03T23:30:00.000Z'); - - register('US/Pacific'); - - const schedule2 = await epgService.parseSchedule( - [ - { - id: '1234-1234-1234-1234-1234', - title: 'Test item 1', - startTime: '2022-07-19T22:00:00Z', - endTime: '2022-07-19T23:30:00Z', - }, - ], - true, - ); - - expect(schedule2.length).toEqual(1); - expect(schedule2[0].startTime).toEqual('2036-06-03T22:00:00.000Z'); - expect(schedule2[0].endTime).toEqual('2036-06-03T23:30:00.000Z'); - }); - - test('transformProgram should transform valid program entries', async () => { - const program1 = await epgService.transformProgram({ - id: '1234-1234-1234-1234', - title: 'Test item', - startTime: '2022-07-19T12:00:00Z', - endTime: '2022-07-19T15:00:00Z', - }); - const program2 = await epgService.transformProgram({ - id: '1234-1234-1234-1234', - title: 'Test item 2', - startTime: '2022-07-19T12:00:00Z', - endTime: '2022-07-19T15:00:00Z', - chapterPointCustomProperties: [], - }); - const program3 = await epgService.transformProgram({ - id: '1234-1234-1234-1234', - title: 'Test item 3', - startTime: '2022-07-19T12:00:00Z', - endTime: '2022-07-19T15:00:00Z', - chapterPointCustomProperties: [ - { - key: 'description', - value: 'A description', - }, - { - key: 'image', - value: 'https://cdn.jwplayer/logo.jpg', - }, - { - key: 'other-key', - value: 'this property should be ignored', - }, - ], - }); - - expect(program1).toEqual({ - id: '1234-1234-1234-1234', - title: 'Test item', - startTime: '2022-07-19T12:00:00Z', - endTime: '2022-07-19T15:00:00Z', - description: undefined, - image: undefined, - cardImage: undefined, - backgroundImage: undefined, - }); - - expect(program2).toEqual({ - id: '1234-1234-1234-1234', - title: 'Test item 2', - startTime: '2022-07-19T12:00:00Z', - endTime: '2022-07-19T15:00:00Z', - description: undefined, - image: undefined, - cardImage: undefined, - backgroundImage: undefined, - }); - - expect(program3).toEqual({ - id: '1234-1234-1234-1234', - title: 'Test item 3', - startTime: '2022-07-19T12:00:00Z', - endTime: '2022-07-19T15:00:00Z', - description: 'A description', - cardImage: 'https://cdn.jwplayer/logo.jpg', - backgroundImage: 'https://cdn.jwplayer/logo.jpg', - }); - }); -}); diff --git a/src/services/epg/epgClient.service.test.ts b/src/services/epg/epgClient.service.test.ts new file mode 100644 index 000000000..804a6e31e --- /dev/null +++ b/src/services/epg/epgClient.service.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect } from 'vitest'; +import { unregister } from 'timezone-mock'; + +import EpgClientService from './epgClient.service'; +import type EpgProviderService from './epgProvider.service'; + +import channel1 from '#test/epg/jwChannel.json'; +import livePlaylistFixture from '#test/fixtures/livePlaylist.json'; +import type { Playlist } from '#types/playlist'; +import { EPG_TYPE } from '#src/config'; + +const livePlaylist = livePlaylistFixture as Playlist; +const epgService = new EpgClientService(); + +const transformProgram = vi.fn(); +const fetchSchedule = vi.fn(); + +const mockProgram1 = { + id: 'test', + title: 'Test', + startTime: '2022-06-03T23:50:00.000Z', + endTime: '2022-06-04T00:55:00.000Z', + cardImage: '', + backgroundImage: '', + description: 'Description', +}; + +const mockProgram2 = { + id: 'test', + title: 'Test', + startTime: '2022-06-04T07:00:00.000Z', + endTime: '2022-06-04T07:40:00.000Z', + cardImage: '', + backgroundImage: '', + description: 'Description', +}; + +vi.mock('#src/modules/container', () => ({ + getNamedModule: (_service: EpgProviderService, type: string) => { + switch (type) { + case EPG_TYPE.JW: + case EPG_TYPE.VIEW_NEXA: + return { + transformProgram, + fetchSchedule, + }; + } + }, +})); + +describe('epgService', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + // must be called before `vi.useRealTimers()` + unregister(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + test('getSchedule fetches and validates a valid schedule', async () => { + const mockProgram = { + id: 'test', + title: 'Test', + startTime: '2022-06-03T23:50:00.000Z', + endTime: '2022-06-03T23:55:00.000Z', + cardImage: '', + backgroundImage: '', + description: 'Description', + }; + + fetchSchedule.mockResolvedValue(channel1); + transformProgram.mockResolvedValue(mockProgram); + + const schedule = await epgService.getSchedule(livePlaylist.playlist[0]); + + expect(schedule.title).toEqual('Channel 1'); + expect(schedule.programs.length).toEqual(33); + expect(schedule.catchupHours).toEqual(7); + }); + + test('getSchedule enables the demo transformer when scheduleDemo is set', async () => { + fetchSchedule.mockResolvedValue(channel1); + transformProgram.mockResolvedValueOnce(mockProgram1); + transformProgram.mockResolvedValue(mockProgram2); + + // mock the date + vi.setSystemTime(new Date(2036, 5, 3, 14, 30, 10, 500)); + + const item = Object.assign({}, livePlaylist.playlist[0]); + item.scheduleDemo = '1'; + + const schedule = await epgService.getSchedule(item); + + expect(schedule.title).toEqual('Channel 1'); + expect(schedule.programs[0].startTime).toEqual('2036-06-03T23:50:00.000Z'); + expect(schedule.programs[0].endTime).toEqual('2036-06-04T00:55:00.000Z'); + + expect(schedule.programs[1].startTime).toEqual('2036-06-04T07:00:00.000Z'); + expect(schedule.programs[1].endTime).toEqual('2036-06-04T07:40:00.000Z'); + }); + + test('parseSchedule should remove programs where validation failed', async () => { + const scheduleItem = { + id: '1234-1234-1234-1234-1234', + title: 'The title', + endTime: '2022-07-19T12:00:00Z', + }; + + transformProgram.mockRejectedValueOnce(undefined); + transformProgram.mockResolvedValueOnce(mockProgram1); + transformProgram.mockRejectedValueOnce(undefined); + transformProgram.mockResolvedValueOnce(mockProgram2); + + const schedule = await epgService.parseSchedule([scheduleItem, scheduleItem, scheduleItem, scheduleItem], livePlaylist.playlist[0]); + + expect(schedule.length).toEqual(2); + }); +}); diff --git a/src/services/epg.service.ts b/src/services/epg/epgClient.service.ts similarity index 54% rename from src/services/epg.service.ts rename to src/services/epg/epgClient.service.ts index d77426350..54d71bc56 100644 --- a/src/services/epg.service.ts +++ b/src/services/epg/epgClient.service.ts @@ -1,14 +1,14 @@ -import { array, object, string } from 'yup'; -import { addDays, differenceInDays, isValid } from 'date-fns'; +import { addDays, differenceInDays } from 'date-fns'; import { injectable } from 'inversify'; -import type { PlaylistItem } from '#types/playlist'; -import { getDataOrThrow } from '#src/utils/api'; +import EpgProviderService from './epgProvider.service'; + import { logDev } from '#src/utils/common'; +import { EPG_TYPE } from '#src/config'; +import { getNamedModule } from '#src/modules/container'; +import type { PlaylistItem } from '#types/playlist'; import type { EpgProgram, EpgChannel } from '#types/epg'; -const AUTHENTICATION_HEADER = 'API-KEY'; - export const isFulfilled = (input: PromiseSettledResult): input is PromiseFulfilledResult => { if (input.status === 'fulfilled') { return true; @@ -18,25 +18,8 @@ export const isFulfilled = (input: PromiseSettledResult): input is Promise return false; }; -const epgProgramSchema = object().shape({ - id: string().required(), - title: string().required(), - startTime: string() - .required() - .test((value) => (value ? isValid(new Date(value)) : false)), - endTime: string() - .required() - .test((value) => (value ? isValid(new Date(value)) : false)), - chapterPointCustomProperties: array().of( - object().shape({ - key: string().required(), - value: string().test('required-but-empty', 'value is required', (value: unknown) => typeof value === 'string'), - }), - ), -}); - @injectable() -export default class EpgService { +export default class EpgClientService { /** * Update the start and end time properties of the given programs with the current date. * This can be used when having a static schedule or while developing @@ -60,32 +43,19 @@ export default class EpgService { } /** - * Validate the given data with the epgProgramSchema and transform it into an EpgProgram + * Ensure the given data validates to the EpgProgram schema */ - transformProgram = async (data: unknown): Promise => { - const program = await epgProgramSchema.validate(data); - const image = program.chapterPointCustomProperties?.find((item) => item.key === 'image')?.value || undefined; + parseSchedule = async (data: unknown, item: PlaylistItem) => { + const demo = !!item.scheduleDemo || false; - return { - id: program.id, - title: program.title, - startTime: program.startTime, - endTime: program.endTime, - cardImage: image, - backgroundImage: image, - description: program.chapterPointCustomProperties?.find((item) => item.key === 'description')?.value || undefined, - }; - }; + const epgService = this.getScheduleProvider(item); - /** - * Ensure the given data validates to the EpgProgram schema - */ - parseSchedule = async (data: unknown, demo = false) => { if (!Array.isArray(data)) return []; const transformResults = await Promise.allSettled( data.map((program) => - this.transformProgram(program) + epgService + .transformProgram(program) // This quiets promise resolution errors in the console .catch((error) => { logDev(error); @@ -102,43 +72,16 @@ export default class EpgService { return demo ? this.generateDemoPrograms(programs) : programs; }; - /** - * Fetch the schedule data for the given PlaylistItem - */ - fetchSchedule = async (item: PlaylistItem) => { - if (!item.scheduleUrl) { - logDev('Tried requesting a schedule for an item with missing `scheduleUrl`', item); - return undefined; - } - - const headers = new Headers(); - - // add authentication token when `scheduleToken` is defined - if (item.scheduleToken) { - headers.set(AUTHENTICATION_HEADER, item.scheduleToken); - } - - try { - const response = await fetch(item.scheduleUrl, { - headers, - }); - - // await needed to ensure the error is caught here - return await getDataOrThrow(response); - } catch (error: unknown) { - if (error instanceof Error) { - logDev(`Fetch failed for EPG schedule: '${item.scheduleUrl}'`, error); - } - } - }; - /** * Fetch and parse the EPG schedule for the given PlaylistItem. * When there is no program (empty schedule) or the request fails, it returns a static program. */ getSchedule = async (item: PlaylistItem) => { - const schedule = await this.fetchSchedule(item); - const programs = await this.parseSchedule(schedule, !!item.scheduleDemo); + const epgService = this.getScheduleProvider(item); + + const schedule = await epgService.fetchSchedule(item); + const programs = await this.parseSchedule(schedule, item); + const catchupHours = item.catchupHours && parseInt(item.catchupHours); return { @@ -152,6 +95,13 @@ export default class EpgService { } as EpgChannel; }; + getScheduleProvider = (item: PlaylistItem) => { + const scheduleType = item.scheduleType || EPG_TYPE.JW; + const scheduleProvider = getNamedModule(EpgProviderService, scheduleType); + + return scheduleProvider; + }; + /** * Get all schedules for the given PlaylistItem's */ diff --git a/src/services/epg/epgProvider.service.ts b/src/services/epg/epgProvider.service.ts new file mode 100644 index 000000000..0684ae127 --- /dev/null +++ b/src/services/epg/epgProvider.service.ts @@ -0,0 +1,14 @@ +import type { EpgProgram } from '#types/epg'; +import type { PlaylistItem } from '#types/playlist'; + +export default abstract class EpgProviderService { + /** + * Fetch the schedule data for the given PlaylistItem + */ + abstract fetchSchedule: (item: PlaylistItem) => Promise; + + /** + * Validate the given data with the schema and transform it into an EpgProgram + */ + abstract transformProgram: (data: unknown) => Promise; +} diff --git a/src/services/epg/jwEpg.service.test.ts b/src/services/epg/jwEpg.service.test.ts new file mode 100644 index 000000000..6aecf4278 --- /dev/null +++ b/src/services/epg/jwEpg.service.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect } from 'vitest'; +import { mockFetch, mockGet } from 'vi-fetch'; +import { unregister } from 'timezone-mock'; + +import JWEpgService from './jwEpg.service'; + +import livePlaylistFixture from '#test/fixtures/livePlaylist.json'; +import type { Playlist } from '#types/playlist'; + +const livePlaylist = livePlaylistFixture as Playlist; +const epgService = new JWEpgService(); + +describe('JWwEpgService', () => { + beforeEach(() => { + mockFetch.clearAll(); + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + // must be called before `vi.useRealTimers()` + unregister(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + test('fetchSchedule performs a request', async () => { + const mock = mockGet('/epg/jwChannel.json').willResolve([]); + const data = await epgService.fetchSchedule(livePlaylist.playlist[0]); + + const request = mock.getRouteCalls()[0]; + const requestHeaders = request?.[1]?.headers; + + expect(data).toEqual([]); + expect(mock).toHaveFetched(); + expect(requestHeaders).toEqual(new Headers()); // no headers expected + }); + + test('fetchSchedule adds authentication token', async () => { + const mock = mockGet('/epg/jwChannel.json').willResolve([]); + const item = Object.assign({}, livePlaylist.playlist[0]); + + item.scheduleToken = 'AUTH-TOKEN'; + const data = await epgService.fetchSchedule(item); + + const request = mock.getRouteCalls()[0]; + const requestHeaders = request?.[1]?.headers; + + expect(data).toEqual([]); + expect(mock).toHaveFetched(); + expect(requestHeaders).toEqual(new Headers({ 'API-KEY': 'AUTH-TOKEN' })); + }); + + test('transformProgram should transform valid program entries', async () => { + const program1 = await epgService.transformProgram({ + id: '1234-1234-1234-1234', + title: 'Test item', + startTime: '2022-07-19T12:00:00Z', + endTime: '2022-07-19T15:00:00Z', + }); + + const program2 = await epgService.transformProgram({ + id: '1234-1234-1234-1234', + title: 'Test item 2', + startTime: '2022-07-19T12:00:00Z', + endTime: '2022-07-19T15:00:00Z', + chapterPointCustomProperties: [], + }); + + const program3 = await epgService.transformProgram({ + id: '1234-1234-1234-1234', + title: 'Test item 3', + startTime: '2022-07-19T12:00:00Z', + endTime: '2022-07-19T15:00:00Z', + chapterPointCustomProperties: [ + { + key: 'description', + value: 'A description', + }, + { + key: 'image', + value: 'https://cdn.jwplayer/logo.jpg', + }, + { + key: 'other-key', + value: 'this property should be ignored', + }, + ], + }); + + expect(program1).toEqual({ + id: '1234-1234-1234-1234', + title: 'Test item', + startTime: '2022-07-19T12:00:00Z', + endTime: '2022-07-19T15:00:00Z', + description: undefined, + image: undefined, + cardImage: undefined, + backgroundImage: undefined, + }); + + expect(program2).toEqual({ + id: '1234-1234-1234-1234', + title: 'Test item 2', + startTime: '2022-07-19T12:00:00Z', + endTime: '2022-07-19T15:00:00Z', + description: undefined, + image: undefined, + cardImage: undefined, + backgroundImage: undefined, + }); + + expect(program3).toEqual({ + id: '1234-1234-1234-1234', + title: 'Test item 3', + startTime: '2022-07-19T12:00:00Z', + endTime: '2022-07-19T15:00:00Z', + description: 'A description', + cardImage: 'https://cdn.jwplayer/logo.jpg', + backgroundImage: 'https://cdn.jwplayer/logo.jpg', + }); + }); + + test('transformProgram should reject invalid entries', async () => { + // missing title + await epgService + .transformProgram({ + id: '1234-1234-1234-1234-1234', + startTime: '2022-07-19T09:00:00Z', + endTime: '2022-07-19T12:00:00Z', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + + // missing startTime + await epgService + .transformProgram({ + id: '1234-1234-1234-1234-1234', + title: 'The title', + endTime: '2022-07-19T12:00:00Z', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + + // missing endTime + await epgService + .transformProgram({ + id: '1234-1234-1234-1234-1234', + title: 'The title', + startTime: '2022-07-19T09:00:00Z', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + + // missing id + await epgService + .transformProgram({ + title: 'The title', + startTime: '2022-07-19T09:00:00Z', + endTime: '2022-07-19T12:00:00Z', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + }); +}); diff --git a/src/services/epg/jwEpg.service.ts b/src/services/epg/jwEpg.service.ts new file mode 100644 index 000000000..e0e87b0d8 --- /dev/null +++ b/src/services/epg/jwEpg.service.ts @@ -0,0 +1,74 @@ +import { array, object, string } from 'yup'; +import { isValid } from 'date-fns'; +import { injectable } from 'inversify'; + +import EpgProviderService from './epgProvider.service'; + +import type { PlaylistItem } from '#types/playlist'; +import { getDataOrThrow } from '#src/utils/api'; +import { logDev } from '#src/utils/common'; +import type { EpgProgram } from '#types/epg'; + +const AUTHENTICATION_HEADER = 'API-KEY'; + +const jwEpgProgramSchema = object().shape({ + id: string().required(), + title: string().required(), + startTime: string() + .required() + .test((value) => (value ? isValid(new Date(value)) : false)), + endTime: string() + .required() + .test((value) => (value ? isValid(new Date(value)) : false)), + chapterPointCustomProperties: array().of( + object().shape({ + key: string().required(), + value: string().test('required-but-empty', 'value is required', (value: unknown) => typeof value === 'string'), + }), + ), +}); + +@injectable() +export default class JWEpgService extends EpgProviderService { + transformProgram = async (data: unknown): Promise => { + const program = await jwEpgProgramSchema.validate(data); + const image = program.chapterPointCustomProperties?.find((item) => item.key === 'image')?.value || undefined; + + return { + id: program.id, + title: program.title, + startTime: program.startTime, + endTime: program.endTime, + cardImage: image, + backgroundImage: image, + description: program.chapterPointCustomProperties?.find((item) => item.key === 'description')?.value || undefined, + }; + }; + + fetchSchedule = async (item: PlaylistItem) => { + if (!item.scheduleUrl) { + logDev('Tried requesting a schedule for an item with missing `scheduleUrl`', item); + return undefined; + } + + const headers = new Headers(); + + // add authentication token when `scheduleToken` is defined + if (item.scheduleToken) { + headers.set(AUTHENTICATION_HEADER, item.scheduleToken); + } + + try { + const response = await fetch(item.scheduleUrl, { + headers, + }); + + // await needed to ensure the error is caught here + return await getDataOrThrow(response); + } catch (error: unknown) { + if (error instanceof Error) { + logDev(`Fetch failed for EPG schedule: '${item.scheduleUrl}'`, error); + } + } + }; +} diff --git a/src/services/epg/viewNexa.service.test.ts b/src/services/epg/viewNexa.service.test.ts new file mode 100644 index 000000000..ad563ced7 --- /dev/null +++ b/src/services/epg/viewNexa.service.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect } from 'vitest'; +import { mockFetch, mockGet } from 'vi-fetch'; +import { unregister } from 'timezone-mock'; + +import ViewNexaEpgService from './viewNexaEpg.service'; + +import viewNexaChannel from '#test/epg/viewNexaChannel.xml?raw'; +import livePlaylistFixture from '#test/fixtures/livePlaylist.json'; +import type { Playlist } from '#types/playlist'; +import { EPG_TYPE } from '#src/config'; + +const livePlaylist = livePlaylistFixture as Playlist; +const epgService = new ViewNexaEpgService(); + +describe('ViewNexaEpgService', () => { + beforeEach(() => { + mockFetch.clearAll(); + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + // must be called before `vi.useRealTimers()` + unregister(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + test('fetchSchedule performs a request', async () => { + const mock = mockGet('/epg/viewNexaChannel.xml').willResolveOnce([]); + const data = await epgService.fetchSchedule({ ...livePlaylist.playlist[0], scheduleUrl: '/epg/viewNexaChannel.xml', scheduleType: EPG_TYPE.VIEW_NEXA }); + + expect(mock).toHaveFetched(); + expect(data).toEqual([]); + }); + + test('fetchSchedule parses xml content', async () => { + const mock = mockGet('/epg/viewNexaChannel.xml').willResolveOnce(viewNexaChannel); + const data = await epgService.fetchSchedule({ ...livePlaylist.playlist[0], scheduleUrl: '/epg/viewNexaChannel.xml', scheduleType: EPG_TYPE.VIEW_NEXA }); + + expect(mock).toHaveFetched(); + expect(data[0]).toEqual({ + channel: 'dc4b6b04-7c6f-49f1-aac1-1ff61cb7d089', + date: 20231120, + desc: { + '#text': + 'Tears of Steel (code-named Project Mango) is a short science fiction film by producer Ton Roosendaal and director/writer Ian Hubert. The film is both live-action and CGI; it was made using new enhancements to the visual effects capabilities of Blender, a free and open-source 3D computer graphics app. Set in a dystopian future, the short film features a group of warriors and scientists who gather at the Oude Kerk in Amsterdam in a desperate attempt to save the world from destructive robots.', + lang: 'en', + }, + 'episode-num': { + '#text': '5a66fb0a-7ad7-4429-a736-168862df98e5', + system: 'assetId', + }, + genre: { + '#text': 'action', + lang: 'en', + }, + icon: { + height: '720', + src: 'https://fueltools-prod01-public.fuelmedia.io/4523afd9-82d5-45ae-9496-786451f2b517/20230330/5a66fb0a-7ad7-4429-a736-168862df98e5/thumbnail_20230330012948467.jpg', + width: '1728', + }, + length: { + '#text': 734.192, + units: 'seconds', + }, + rating: { + system: 'bfcc', + value: 'CC-BY', + }, + start: '20231120073000 +0000', + stop: '20231120105014 +0000', + title: { + '#text': 'Tears of Steel', + lang: 'en', + }, + }); + }); + + test('transformProgram should transform valid program entries', async () => { + const program1 = await epgService.transformProgram({ + 'episode-num': { + '#text': '1234-1234-1234-1234', + }, + title: { + '#text': 'Test item', + }, + desc: { + '#text': 'Test desc', + }, + start: '20231120073000 +0000', + stop: '20231120105014 +0000', + }); + + const program2 = await epgService.transformProgram({ + 'episode-num': { + '#text': '1234-1234-1234-1234', + }, + title: { + '#text': 'Test item 2', + }, + desc: { + '#text': 'Test desc', + }, + start: '20231120073000 +0000', + stop: '20231120105014 +0000', + }); + + const program3 = await epgService.transformProgram({ + 'episode-num': { + '#text': '1234-1234-1234-1234', + }, + title: { + '#text': 'Test item 3', + }, + desc: { + '#text': 'A description', + }, + icon: { + src: 'https://cdn.jwplayer/logo.jpg', + }, + start: '20231120073000 +0000', + stop: '20231120105014 +0000', + }); + + expect(program1).toEqual({ + id: '1234-1234-1234-1234', + title: 'Test item', + startTime: '2023-11-20T07:30:00.000Z', + endTime: '2023-11-20T10:50:14.000Z', + description: 'Test desc', + cardImage: undefined, + backgroundImage: undefined, + }); + + expect(program2).toEqual({ + id: '1234-1234-1234-1234', + title: 'Test item 2', + startTime: '2023-11-20T07:30:00.000Z', + endTime: '2023-11-20T10:50:14.000Z', + description: 'Test desc', + cardImage: undefined, + backgroundImage: undefined, + }); + + expect(program3).toEqual({ + id: '1234-1234-1234-1234', + title: 'Test item 3', + startTime: '2023-11-20T07:30:00.000Z', + endTime: '2023-11-20T10:50:14.000Z', + description: 'A description', + cardImage: 'https://cdn.jwplayer/logo.jpg', + backgroundImage: 'https://cdn.jwplayer/logo.jpg', + }); + }); + + test('transformProgram should reject invalid entries', async () => { + // missing title + await epgService + .transformProgram({ + 'episode-num': { + '#text': '1234-1234-1234-1234', + }, + desc: { + '#text': 'A description', + }, + icon: { + src: 'https://cdn.jwplayer/logo.jpg', + }, + start: '20231120073000 +0000', + stop: '20231120105014 +0000', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + + // missing startTime + await epgService + .transformProgram({ + 'episode-num': { + '#text': '1234-1234-1234-1234', + }, + title: { + '#text': 'Test item 3', + }, + desc: { + '#text': 'A description', + }, + icon: { + src: 'https://cdn.jwplayer/logo.jpg', + }, + stop: '20231120105014 +0000', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + + // missing endTime + await epgService + .transformProgram({ + 'episode-num': { + '#text': '1234-1234-1234-1234', + }, + title: { + '#text': 'Test item 3', + }, + desc: { + '#text': 'A description', + }, + icon: { + src: 'https://cdn.jwplayer/logo.jpg', + }, + start: '20231120073000 +0000', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + + // missing id + await epgService + .transformProgram({ + 'episode-num': { + '#text': '1234-1234-1234-1234', + }, + title: { + '#text': 'Test item 3', + }, + desc: { + '#text': 'A description', + }, + icon: { + src: 'https://cdn.jwplayer/logo.jpg', + }, + start: '20231120073000 +0000', + stop: '20231120105014 +0000', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + }); +}); diff --git a/src/services/epg/viewNexaEpg.service.ts b/src/services/epg/viewNexaEpg.service.ts new file mode 100644 index 000000000..6e58b6601 --- /dev/null +++ b/src/services/epg/viewNexaEpg.service.ts @@ -0,0 +1,79 @@ +import { object, string } from 'yup'; +import { parse } from 'date-fns'; +import { injectable } from 'inversify'; + +import EpgProviderService from './epgProvider.service'; + +import type { PlaylistItem } from '#types/playlist'; +import { logDev } from '#src/utils/common'; +import type { EpgProgram } from '#types/epg'; + +const viewNexaEpgProgramSchema = object().shape({ + 'episode-num': object().shape({ + '#text': string().required(), + }), + title: object().shape({ + '#text': string().required(), + }), + desc: object().shape({ + '#text': string(), + }), + icon: object().shape({ + src: string(), + }), + start: string().required(), + stop: string().required(), +}); + +const parseData = (date: string): string => parse(date, 'yyyyMdHms xxxx', new Date()).toISOString(); + +@injectable() +export default class ViewNexaEpgService extends EpgProviderService { + /** + * Validate the given data with the viewNexaProgramSchema and transform it into an EpgProgram + */ + transformProgram = async (data: unknown): Promise => { + const program = await viewNexaEpgProgramSchema.validate(data); + + return { + id: program['episode-num']['#text'], + title: program['title']['#text'], + description: program['desc']['#text'], + startTime: parseData(program['start']), + endTime: parseData(program['stop']), + cardImage: program['icon']['src'], + backgroundImage: program['icon']['src'], + }; + }; + + /** + * Fetch the schedule data for the given PlaylistItem + */ + fetchSchedule = async (item: PlaylistItem) => { + const { XMLParser } = await import('fast-xml-parser'); + + const scheduleUrl = item.scheduleUrl; + + if (!scheduleUrl) { + logDev('Tried requesting a schedule for an item with missing `scheduleUrl`', item); + return undefined; + } + + const xmlParserOptions = { + ignoreAttributes: false, + attributeNamePrefix: '', + }; + + try { + const data = await fetch(scheduleUrl).then((res) => res.text()); + const parser = new XMLParser(xmlParserOptions); + const schedule = parser.parse(data); + + return schedule?.tv?.programme || []; + } catch (error: unknown) { + if (error instanceof Error) { + logDev(`Fetch failed for View Nexa EPG schedule: '${scheduleUrl}'`, error); + } + } + }; +} diff --git a/src/utils/media.ts b/src/utils/media.ts index 96ccc3309..b7cffcece 100644 --- a/src/utils/media.ts +++ b/src/utils/media.ts @@ -48,4 +48,4 @@ export const getLegacySeriesPlaylistIdFromEpisodeTags = (item: PlaylistItem | un }; export const isLiveChannel = (item: PlaylistItem): item is RequiredProperties => - item.contentType === 'LiveChannel' && !!item.liveChannelsId; + item.contentType?.toLowerCase() === CONTENT_TYPE.livechannel && !!item.liveChannelsId; diff --git a/test/epg/jwChannel.json b/test/epg/jwChannel.json new file mode 100644 index 000000000..6abf0f988 --- /dev/null +++ b/test/epg/jwChannel.json @@ -0,0 +1,530 @@ +[ + { + "id": "00990617-c229-4d1e-b341-7fe100b36b3c", + "title": "Peaky Blinders", + "startTime": "2022-07-15T00:05:00Z", + "endTime": "2022-07-15T00:55:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "A gangster family epic set in 1900s England, centering on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/wiE9doxiLwq3WCGamDIOb2PqBqc.jpg" + } + ] + }, + { + "id": "45a70c27-1681-4da3-ad3b-73fa7855ee6a", + "title": "Friends", + "startTime": "2022-07-15T00:55:00Z", + "endTime": "2022-07-15T02:35:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "Follows the personal and professional lives of six twenty to thirty-something-year-old friends living in Manhattan." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/l0qVZIpXtIo7km9u5Yqh0nKPOr5.jpg" + } + ] + }, + { + "id": "d7846aa7-cd76-49eb-be7c-2e183a920d9e", + "title": "Malcolm in the Middle", + "startTime": "2022-07-15T02:35:00Z", + "endTime": "2022-07-15T03:30:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "A gifted young teen tries to survive life with his dimwitted, dysfunctional family." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/ysaA0BInz4071p3LKqAQnWKZCsK.jpg" + } + ] + }, + { + "id": "03c2932e-8590-4ab1-bff5-8894558c34d1", + "title": "The Simpsons", + "startTime": "2022-07-15T03:30:00Z", + "endTime": "2022-07-15T04:15:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "The satiric adventures of a working-class family in the misfit city of Springfield." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/hpU2cHC9tk90hswCFEpf5AtbqoL.jpg" + } + ] + }, + { + "id": "317cfd01-85db-4a2b-a0c8-af1d5ca52686", + "title": "That '70s Show", + "startTime": "2022-07-15T04:15:00Z", + "endTime": "2022-07-15T04:30:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "A comedy revolving around a group of teenage friends, their mishaps, and their coming of age, set in 1970s Wisconsin." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/3zRUiH8erHIgNUBTj05JT00HwsS.jpg" + } + ] + }, + { + "id": "449faed1-be35-4347-ba4a-39dc433bb1d3", + "title": "That '70s Show", + "startTime": "2022-07-15T04:30:00Z", + "endTime": "2022-07-15T05:10:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "A comedy revolving around a group of teenage friends, their mishaps, and their coming of age, set in 1970s Wisconsin." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/3zRUiH8erHIgNUBTj05JT00HwsS.jpg" + } + ] + }, + { + "id": "4430e87c-4491-4b93-ae16-5941474a3d1c", + "title": "How I Met Your Mother", + "startTime": "2022-07-15T05:10:00Z", + "endTime": "2022-07-15T05:35:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "A father recounts to his children - through a series of flashbacks - the journey he and his four best friends took leading up to him meeting their mother." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/gvEisYtZ0iBMjnO3zqFU2oM26oM.jpg" + } + ] + }, + { + "id": "481cfbd1-9d0b-413c-9379-38608d8f7fd0", + "title": "Malcolm in the Middle", + "startTime": "2022-07-15T05:35:00Z", + "endTime": "2022-07-15T06:00:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "A gifted young teen tries to survive life with his dimwitted, dysfunctional family." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/ysaA0BInz4071p3LKqAQnWKZCsK.jpg" + } + ] + }, + { + "id": "f7cae874-cb64-46ac-b1d7-05db47d88b22", + "title": "Euphoria", + "startTime": "2022-07-15T06:00:00Z", + "endTime": "2022-07-15T07:00:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "A look at life for a group of high school students as they grapple with issues of drugs, sex, and violence." + }, + { + "key": "image", + "value": "https://images.fanart.tv/fanart/euphoria-2019-5ec8c62aa702d.jpg" + } + ] + }, + { + "id": "a2f80690-86e6-46e7-8a78-b243d5c8237e", + "title": "The Flash", + "startTime": "2022-07-15T07:00:00Z", + "endTime": "2022-07-15T07:40:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "After being struck by lightning, Barry Allen wakes up from his coma to discover he's been given the power of super speed, becoming the Flash, fighting crime in Central City." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/akuD37ySZGUkXR7LNb1oOHCboy8.jpg" + } + ] + }, + { + "id": "f99402a4-783d-4298-9ca2-d392db317927", + "title": "The Daily Show with Trevor Noah: Ears Edition", + "startTime": "2022-07-15T07:40:00Z", + "endTime": "2022-07-15T08:05:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "Listen to highlights and extended interviews in the \"Ears Edition\" of The Daily Show with Trevor Noah. From Comedy Central's Podcast Network." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/uyilhJ7MBLjiaQXboaEwe44Z0jA.jpg" + } + ] + }, + { + "id": "6ffd595b-3cdb-431b-b444-934a0e4f63dc", + "title": "The Flash", + "startTime": "2022-07-15T08:05:00Z", + "endTime": "2022-07-15T08:20:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "After being struck by lightning, Barry Allen wakes up from his coma to discover he's been given the power of super speed, becoming the Flash, fighting crime in Central City." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/akuD37ySZGUkXR7LNb1oOHCboy8.jpg" + } + ] + }, + { + "id": "78c4442f-2df0-4c75-8cf7-42d249f917b6", + "title": "Friends", + "startTime": "2022-07-15T08:20:00Z", + "endTime": "2022-07-15T08:45:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "Follows the personal and professional lives of six twenty to thirty-something-year-old friends living in Manhattan." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/l0qVZIpXtIo7km9u5Yqh0nKPOr5.jpg" + } + ] + }, + { + "id": "f54c4ead-8222-4ba3-99db-53d466df5fe3", + "title": "Malcolm in the Middle", + "startTime": "2022-07-15T08:45:00Z", + "endTime": "2022-07-15T09:15:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "A gifted young teen tries to survive life with his dimwitted, dysfunctional family." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/ysaA0BInz4071p3LKqAQnWKZCsK.jpg" + } + ] + }, + { + "id": "ab958a02-a41c-4567-ae66-b87b7d6a1a77", + "title": "The Book of Boba Fett", + "startTime": "2022-07-15T09:15:00Z", + "endTime": "2022-07-15T10:20:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "Bounty hunter Boba Fett & mercenary Fennec Shand navigate the underworld when they return to Tatooine to claim Jabba the Hutt's old turf." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/sjx6zjQI2dLGtEL0HGWsnq6UyLU.jpg" + } + ] + }, + { + "id": "5f69ebea-a10f-4acf-9529-a9247dcf8bdf", + "title": "Peaky Blinders", + "startTime": "2022-07-15T10:20:00Z", + "endTime": "2022-07-15T11:15:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "A gangster family epic set in 1900s England, centering on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/wiE9doxiLwq3WCGamDIOb2PqBqc.jpg" + } + ] + }, + { + "id": "742e3a99-4463-46cf-bef2-a6e2765334ab", + "title": "The Daily Show with Trevor Noah: Ears Edition", + "startTime": "2022-07-15T11:15:00Z", + "endTime": "2022-07-15T11:50:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "Listen to highlights and extended interviews in the \"Ears Edition\" of The Daily Show with Trevor Noah. From Comedy Central's Podcast Network." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/uyilhJ7MBLjiaQXboaEwe44Z0jA.jpg" + } + ] + }, + { + "id": "df272417-c45d-44e5-b928-4d3fb7ff3a76", + "title": "The Flash", + "startTime": "2022-07-15T11:50:00Z", + "endTime": "2022-07-15T13:45:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "After being struck by lightning, Barry Allen wakes up from his coma to discover he's been given the power of super speed, becoming the Flash, fighting crime in Central City." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/akuD37ySZGUkXR7LNb1oOHCboy8.jpg" + } + ] + }, + { + "id": "24cb5ed1-f5eb-4dc1-a64b-4396aa7d5ec9", + "title": "And Just Like That...", + "startTime": "2022-07-15T13:45:00Z", + "endTime": "2022-07-15T13:55:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "The series will follow Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life and friendship in their 50s." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/1CqkiWeuwlaMswG02q3mBmraiDM.jpg" + } + ] + }, + { + "id": "b324b65c-0678-42a8-b216-2f1b06ab7b1b", + "title": "The Silent Sea", + "startTime": "2022-07-15T13:55:00Z", + "endTime": "2022-07-15T14:55:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "During a perilous 24-hour mission on the moon, space explorers try to retrieve samples from an abandoned research facility steeped in classified secrets." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/9hNJ3fvIVd4WE3rU1Us2awoTpgM.jpg" + } + ] + }, + { + "id": "ab3f3968-c693-4c2c-a999-a5811680611c", + "title": "The Flash", + "startTime": "2022-07-15T14:55:00Z", + "endTime": "2022-07-15T15:55:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "After being struck by lightning, Barry Allen wakes up from his coma to discover he's been given the power of super speed, becoming the Flash, fighting crime in Central City." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/akuD37ySZGUkXR7LNb1oOHCboy8.jpg" + } + ] + }, + { + "id": "4ca8eb5e-e8bd-4157-915e-0c5a43f4b0bf", + "title": "And Just Like That...", + "startTime": "2022-07-15T15:55:00Z", + "endTime": "2022-07-15T17:00:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "The series will follow Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life and friendship in their 50s." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/1CqkiWeuwlaMswG02q3mBmraiDM.jpg" + } + ] + }, + { + "id": "83d29f9f-7787-4644-9a9c-d4925522c68d", + "title": "SpongeBob SquarePants", + "startTime": "2022-07-15T17:00:00Z", + "endTime": "2022-07-15T17:20:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "The misadventures of a talking sea sponge who works at a fast food restaurant, attends a boating school, and lives in an underwater pineapple." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/maFEWU41jdUOzDfRVkojq7fluIm.jpg" + } + ] + }, + { + "id": "746cbddb-b5b4-42e6-bb10-3c2d2e8e25c9", + "title": "The Daily Show with Trevor Noah: Ears Edition", + "startTime": "2022-07-15T17:20:00Z", + "endTime": "2022-07-15T17:30:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "Listen to highlights and extended interviews in the \"Ears Edition\" of The Daily Show with Trevor Noah. From Comedy Central's Podcast Network." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/uyilhJ7MBLjiaQXboaEwe44Z0jA.jpg" + } + ] + }, + { + "id": "89ad2461-7eb9-4896-a9d5-d4f92eb60130", + "title": "Late Night with Jimmy Fallon", + "startTime": "2022-07-15T17:30:00Z", + "endTime": "2022-07-15T18:30:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "Comedian Jimmy Fallon hosts a late-night talk show." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/uu5FuSKleCLh0Kq2pmGzlPH3aeS.jpg" + } + ] + }, + { + "id": "a87ed404-05ac-48e2-b56c-a30f3acb792e", + "title": "House", + "startTime": "2022-07-15T18:30:00Z", + "endTime": "2022-07-15T19:25:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "An antisocial maverick doctor who specializes in diagnostic medicine does whatever it takes to solve puzzling cases that come his way using his crack team of doctors and his wits." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/hiK4qc0tZijQ9KNUnBIS1k4tdMJ.jpg" + } + ] + }, + { + "id": "19685580-f104-4cfe-9d63-f3144fb6a0d4", + "title": "Peaky Blinders", + "startTime": "2022-07-15T19:25:00Z", + "endTime": "2022-07-15T19:30:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "A gangster family epic set in 1900s England, centering on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/wiE9doxiLwq3WCGamDIOb2PqBqc.jpg" + } + ] + }, + { + "id": "d061dd8e-6b9b-4efc-977c-58e7cae91168", + "title": "The Simpsons", + "startTime": "2022-07-15T19:30:00Z", + "endTime": "2022-07-15T20:05:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "The satiric adventures of a working-class family in the misfit city of Springfield." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/hpU2cHC9tk90hswCFEpf5AtbqoL.jpg" + } + ] + }, + { + "id": "bf8c53c2-63bd-455c-8f96-0fcc97068573", + "title": "The Silent Sea", + "startTime": "2022-07-15T20:05:00Z", + "endTime": "2022-07-15T20:10:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "During a perilous 24-hour mission on the moon, space explorers try to retrieve samples from an abandoned research facility steeped in classified secrets." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/9hNJ3fvIVd4WE3rU1Us2awoTpgM.jpg" + } + ] + }, + { + "id": "7aeb5b08-da25-46ed-a409-a047acf9e941", + "title": "The Fairly OddParents", + "startTime": "2022-07-15T20:10:00Z", + "endTime": "2022-07-15T20:35:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "Follow Timmy Turner's cousin, Vivian \"Viv\" Turner, and her new stepbrother, Roy Ragland, as they navigate life in Dimmsdale with the help of their fairy godparents, Wanda and Cosmo." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/jlruzecsif3tkCSoHlUaPR01O7U.jpg" + } + ] + }, + { + "id": "7c146e38-ee9b-4193-8c59-9a2b96ad95b6", + "title": "SpongeBob SquarePants", + "startTime": "2022-07-15T20:35:00Z", + "endTime": "2022-07-15T21:35:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "The misadventures of a talking sea sponge who works at a fast food restaurant, attends a boating school, and lives in an underwater pineapple." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/maFEWU41jdUOzDfRVkojq7fluIm.jpg" + } + ] + }, + { + "id": "70850251-2c81-4547-8b3e-88bb33b8ede9", + "title": "Peaky Blinders", + "startTime": "2022-07-15T21:35:00Z", + "endTime": "2022-07-15T23:30:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "A gangster family epic set in 1900s England, centering on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/wiE9doxiLwq3WCGamDIOb2PqBqc.jpg" + } + ] + }, + { + "id": "981b69ad-1a8d-436e-bf26-fa2bfb4697e5", + "title": "The Fairly OddParents", + "startTime": "2022-07-15T23:30:00Z", + "endTime": "2022-07-16T01:25:00Z", + "chapterPointCustomProperties": [ + { + "key": "description", + "value": "Follow Timmy Turner's cousin, Vivian \"Viv\" Turner, and her new stepbrother, Roy Ragland, as they navigate life in Dimmsdale with the help of their fairy godparents, Wanda and Cosmo." + }, + { + "key": "image", + "value": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/jlruzecsif3tkCSoHlUaPR01O7U.jpg" + } + ] + } +] diff --git a/test/epg/viewNexaChannel.xml b/test/epg/viewNexaChannel.xml new file mode 100644 index 000000000..114523005 --- /dev/null +++ b/test/epg/viewNexaChannel.xml @@ -0,0 +1,91 @@ + + + + + + + + <![CDATA[Tears of Steel]]> + + + + + + + + + + + + <![CDATA[The Lost World: Jurassic Park]]> + + + + + + + + + + + + <![CDATA[Spider Man: Homecoming [Captions]]]> + + + + + + + + + + + + <![CDATA[Sintel (PPI)]]> + + + + + + + + + + + + <![CDATA[Spring (Premium)]]> + + + + + + + + + + + + <![CDATA[Hero]]> + + + + + + + + + + + + <![CDATA[Tears of Steel]]> + + + + + + + + + + + diff --git a/test/fixtures/livePlaylist.json b/test/fixtures/livePlaylist.json index 918c9d122..ae207aa61 100644 --- a/test/fixtures/livePlaylist.json +++ b/test/fixtures/livePlaylist.json @@ -50,7 +50,8 @@ "pubdate": 1615480440, "description": "A 24x7 airing of long films released by Blender Open Movie Project, including Elephants Dream, Big Buck Bunny, Sintel, Tears Of Steel, and Cosmos Laundromat.", "tags": "live", - "scheduleUrl": "/epg/channel1.json", + "scheduleType": "JW", + "scheduleUrl": "/epg/jwChannel.json", "catchupHours": "7", "sources": [ { @@ -103,7 +104,8 @@ "pubdate": 1615480440, "description": "A 24x7 airing of long films released by Blender Open Movie Project, including Elephants Dream, Big Buck Bunny, Sintel, Tears Of Steel, and Cosmos Laundromat.", "tags": "live", - "scheduleUrl": "/epg/channel2.json", + "scheduleType": "JW", + "scheduleUrl": "/epg/jwChannel.json", "sources": [ { "file": "https://demo-use1.cdn.vustreams.com/live/b49bec86-f786-4b08-941c-a4ee80f70e1f/live.isml/b49bec86-f786-4b08-941c-a4ee80f70e1f.m3u8", @@ -155,6 +157,7 @@ "pubdate": 1615480440, "description": "A 24x7 airing of long films released by Blender Open Movie Project, including Elephants Dream, Big Buck Bunny, Sintel, Tears Of Steel, and Cosmos Laundromat.", "tags": "live", + "scheduleType": "JW", "scheduleUrl": "/epg/does-not-exist.json", "sources": [ { @@ -207,6 +210,7 @@ "pubdate": 1615480440, "description": "A 24x7 airing of long films released by Blender Open Movie Project, including Elephants Dream, Big Buck Bunny, Sintel, Tears Of Steel, and Cosmos Laundromat.", "tags": "live", + "scheduleType": "JW", "scheduleUrl": "/epg/network-error.json", "sources": [ { diff --git a/types/playlist.d.ts b/types/playlist.d.ts index 3d601f89c..8e5c39225 100644 --- a/types/playlist.d.ts +++ b/types/playlist.d.ts @@ -57,6 +57,7 @@ export type PlaylistItem = { scheduledStart?: Date; scheduledEnd?: Date; markdown?: string; + scheduleType?: string; [key: string]: unknown; }; diff --git a/types/static.d.ts b/types/static.d.ts index ba74d2a2a..8e3e2b15d 100644 --- a/types/static.d.ts +++ b/types/static.d.ts @@ -55,5 +55,9 @@ declare module '*.png' { const ref: string; export default ref; } +declare module '*.xml' { + const ref: string; + export default ref; +} /* CUSTOM: ADD YOUR OWN HERE */ diff --git a/vite.config.ts b/vite.config.ts index cd877357e..32c55be0f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -87,6 +87,7 @@ export default ({ mode, command }: ConfigEnv): UserConfigExport => { port: 8080, }, mode: mode, + assetsInclude: mode === 'test' ? ['**/*.xml'] : [], build: { outDir: './build/public', cssCodeSplit: false, diff --git a/yarn.lock b/yarn.lock index e0650c5aa..b2f880e62 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4597,6 +4597,13 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-xml-parser@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.3.2.tgz#761e641260706d6e13251c4ef8e3f5694d4b0d79" + integrity sha512-rmrXUXwbJedoXkStenj1kkljNF7ugn5ZjR9FJcwmCfcCbtOMDghPajbc+Tck6vE6F5XsDmx+Pr2le9fw8+pXBg== + dependencies: + strnum "^1.0.5" + fastest-levenshtein@^1.0.12: version "1.0.16" resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" @@ -8797,6 +8804,11 @@ strip-outer@^1.0.1: dependencies: escape-string-regexp "^1.0.2" +strnum@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db" + integrity sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA== + style-search@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/style-search/-/style-search-0.1.0.tgz#7958c793e47e32e07d2b5cafe5c0bf8e12e77902" From 4154488dbe0e157ea92434382cc5d2d646cdd2bb Mon Sep 17 00:00:00 2001 From: Anton Lantukh Date: Mon, 20 Nov 2023 16:12:03 +0100 Subject: [PATCH 02/18] feat(project): change the way of DI --- src/modules/register.ts | 12 +++++++++--- src/services/epg/epgClient.service.test.ts | 4 +++- src/services/epg/epgClient.service.ts | 16 ++++++++++------ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/modules/register.ts b/src/modules/register.ts index df2b8a612..75300dd3c 100644 --- a/src/modules/register.ts +++ b/src/modules/register.ts @@ -14,7 +14,7 @@ import SettingsService from '#src/services/settings.service'; // Epg services import EpgClientService from '#src/services/epg/epgClient.service'; -import EpgProvider from '#src/services/epg/epgProvider.service'; +import EpgProviderService from '#src/services/epg/epgProvider.service'; import ViewNexaEpgService from '#src/services/epg/viewNexaEpg.service'; import JWEpgService from '#src/services/epg/jwEpg.service'; @@ -80,5 +80,11 @@ container.bind(ProfileService).to(InplayerProfileService).whenTargetNamed(INTEGR // EPG integration container.bind(EpgClientService).toSelf(); -container.bind(EpgProvider).to(JWEpgService).whenTargetNamed(EPG_TYPE.JW); -container.bind(EpgProvider).to(ViewNexaEpgService).whenTargetNamed(EPG_TYPE.VIEW_NEXA); +container + .bind(EpgProviderService) + .to(ViewNexaEpgService) + .when((request) => request.target.name.equals(EPG_TYPE.VIEW_NEXA)); +container + .bind(EpgProviderService) + .to(JWEpgService) + .when((request) => request.target.name.equals(EPG_TYPE.JW)); diff --git a/src/services/epg/epgClient.service.test.ts b/src/services/epg/epgClient.service.test.ts index 804a6e31e..bff9dfaff 100644 --- a/src/services/epg/epgClient.service.test.ts +++ b/src/services/epg/epgClient.service.test.ts @@ -10,11 +10,13 @@ import type { Playlist } from '#types/playlist'; import { EPG_TYPE } from '#src/config'; const livePlaylist = livePlaylistFixture as Playlist; -const epgService = new EpgClientService(); const transformProgram = vi.fn(); const fetchSchedule = vi.fn(); +const epgProvider = { transformProgram, fetchSchedule }; +const epgService = new EpgClientService(epgProvider, epgProvider); + const mockProgram1 = { id: 'test', title: 'Test', diff --git a/src/services/epg/epgClient.service.ts b/src/services/epg/epgClient.service.ts index 54d71bc56..8be758e20 100644 --- a/src/services/epg/epgClient.service.ts +++ b/src/services/epg/epgClient.service.ts @@ -1,11 +1,10 @@ import { addDays, differenceInDays } from 'date-fns'; -import { injectable } from 'inversify'; +import { injectable, targetName } from 'inversify'; import EpgProviderService from './epgProvider.service'; import { logDev } from '#src/utils/common'; import { EPG_TYPE } from '#src/config'; -import { getNamedModule } from '#src/modules/container'; import type { PlaylistItem } from '#types/playlist'; import type { EpgProgram, EpgChannel } from '#types/epg'; @@ -20,6 +19,14 @@ export const isFulfilled = (input: PromiseSettledResult): input is Promise @injectable() export default class EpgClientService { + private viewNexaProvider: EpgProviderService; + private jwProvider: EpgProviderService; + + public constructor(@targetName(EPG_TYPE.JW) jwProvider: EpgProviderService, @targetName(EPG_TYPE.VIEW_NEXA) viewNexaProvider: EpgProviderService) { + this.viewNexaProvider = viewNexaProvider; + this.jwProvider = jwProvider; + } + /** * Update the start and end time properties of the given programs with the current date. * This can be used when having a static schedule or while developing @@ -96,10 +103,7 @@ export default class EpgClientService { }; getScheduleProvider = (item: PlaylistItem) => { - const scheduleType = item.scheduleType || EPG_TYPE.JW; - const scheduleProvider = getNamedModule(EpgProviderService, scheduleType); - - return scheduleProvider; + return item?.scheduleType === EPG_TYPE.VIEW_NEXA ? this.viewNexaProvider : this.jwProvider; }; /** From 3359612cd029858a2ea3f596d23cfc835bda2fc8 Mon Sep 17 00:00:00 2001 From: Anton Lantukh Date: Tue, 5 Dec 2023 16:45:02 +0100 Subject: [PATCH 03/18] feat(project): review comments --- src/config.ts | 2 +- src/hooks/useLiveChannels.test.ts | 6 ++-- src/hooks/useLiveChannels.ts | 6 ++-- src/modules/container.ts | 14 ++++---- src/modules/register.ts | 26 ++++++-------- ...{epgProvider.service.ts => epg.service.ts} | 8 ++++- ...service.test.ts => jw.epg.service.test.ts} | 2 +- .../{jwEpg.service.ts => jw.epg.service.ts} | 9 +++-- ...e.test.ts => viewNexa.epg.service.test.ts} | 2 +- ...Epg.service.ts => viewNexa.epg.service.ts} | 15 +++++--- .../EpgController.test.ts} | 36 +++++++++---------- .../EpgController.ts} | 32 +++++++++-------- test/fixtures/livePlaylist.json | 2 +- types/epg.d.ts | 4 +++ 14 files changed, 89 insertions(+), 75 deletions(-) rename src/services/epg/{epgProvider.service.ts => epg.service.ts} (70%) rename src/services/epg/{jwEpg.service.test.ts => jw.epg.service.test.ts} (99%) rename src/services/epg/{jwEpg.service.ts => jw.epg.service.ts} (92%) rename src/services/epg/{viewNexa.service.test.ts => viewNexa.epg.service.test.ts} (99%) rename src/services/epg/{viewNexaEpg.service.ts => viewNexa.epg.service.ts} (85%) rename src/{services/epg/epgClient.service.test.ts => stores/EpgController.test.ts} (78%) rename src/{services/epg/epgClient.service.ts => stores/EpgController.ts} (81%) diff --git a/src/config.ts b/src/config.ts index f83aa6cb5..c44f024b1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -75,6 +75,6 @@ export const DEFAULT_FEATURES = { }; export const EPG_TYPE = { - JW: 'JW', + JWP: 'JWP', VIEW_NEXA: 'VIEW_NEXA', } as const; diff --git a/src/hooks/useLiveChannels.test.ts b/src/hooks/useLiveChannels.test.ts index c33e00821..503968a5b 100644 --- a/src/hooks/useLiveChannels.test.ts +++ b/src/hooks/useLiveChannels.test.ts @@ -8,7 +8,7 @@ import type { Playlist } from '#types/playlist'; import livePlaylistFixture from '#test/fixtures/livePlaylist.json'; import epgChannelsFixture from '#test/fixtures/epgChannels.json'; import epgChannelsUpdateFixture from '#test/fixtures/epgChannelsUpdate.json'; -import EpgClientService from '#src/services/epg/epgClient.service'; +import EpgController from '#src/stores/EpgController'; const livePlaylist: Playlist = livePlaylistFixture; const schedule: EpgChannel[] = epgChannelsFixture; @@ -17,9 +17,9 @@ const scheduleUpdate: EpgChannel[] = epgChannelsUpdateFixture; const mockSchedule = vi.fn(); vi.mock('#src/modules/container', () => ({ - getModule: (type: typeof EpgClientService) => { + getModule: (type: typeof EpgController) => { switch (type) { - case EpgClientService: + case EpgController: return { getSchedules: mockSchedule }; } }, diff --git a/src/hooks/useLiveChannels.ts b/src/hooks/useLiveChannels.ts index cc0776839..2573fb1c7 100644 --- a/src/hooks/useLiveChannels.ts +++ b/src/hooks/useLiveChannels.ts @@ -6,7 +6,7 @@ import type { EpgProgram, EpgChannel } from '#types/epg'; import { getLiveProgram, programIsLive } from '#src/utils/epg'; import { LIVE_CHANNELS_REFETCH_INTERVAL } from '#src/config'; import { getModule } from '#src/modules/container'; -import EpgClientService from '#src/services/epg/epgClient.service'; +import EpgController from '#src/stores/EpgController'; /** * This hook fetches the schedules for the given list of playlist items and manages the current channel and program. @@ -31,9 +31,9 @@ const useLiveChannels = ({ initialChannelId: string | undefined; enableAutoUpdate?: boolean; }) => { - const epgService = getModule(EpgClientService); + const epgController = getModule(EpgController); - const { data: channels = [] } = useQuery(['schedules', ...playlist.map(({ mediaid }) => mediaid)], () => epgService.getSchedules(playlist), { + const { data: channels = [] } = useQuery(['schedules', ...playlist.map(({ mediaid }) => mediaid)], () => epgController.getSchedules(playlist), { refetchInterval: LIVE_CHANNELS_REFETCH_INTERVAL, }); diff --git a/src/modules/container.ts b/src/modules/container.ts index 14d4b5f41..03e1208ec 100644 --- a/src/modules/container.ts +++ b/src/modules/container.ts @@ -13,23 +13,23 @@ export function getModule(constructorFunction: interfaces.ServiceIdentifier(constructorFunction: interfaces.ServiceIdentifier, integration: string | null, required: false): T | undefined; -export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, integration: string | null, required: true): T; -export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, integration: string | null): T; -export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, integration: string | null, required = true): T | undefined { - if (!integration) { +export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, name: string | null, required: false): T | undefined; +export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, name: string | null, required: true): T; +export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, name: string | null): T; +export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, name: string | null, required = true): T | undefined { + if (!name) { return; } let module; try { - module = container.getAllNamed(constructorFunction, integration)[0]; + module = container.getAllNamed(constructorFunction, name)[0]; return module; } catch (err: unknown) { if (required) { - throw new Error(`Service not found '${String(constructorFunction)}' with name '${integration}'`); + throw new Error(`Service not found '${String(constructorFunction)}' with name '${name}'`); } } } diff --git a/src/modules/register.ts b/src/modules/register.ts index 75300dd3c..581ddd8c7 100644 --- a/src/modules/register.ts +++ b/src/modules/register.ts @@ -1,7 +1,7 @@ // To organize imports in a better way /* eslint-disable import/order */ import 'reflect-metadata'; // include once in the app for inversify (see: https://github.com/inversify/InversifyJS/blob/master/README.md#-installation) -import { EPG_TYPE, INTEGRATION } from '#src/config'; +import { INTEGRATION } from '#src/config'; import { container } from '#src/modules/container'; import ApiService from '#src/services/api.service'; @@ -13,10 +13,9 @@ import ConfigService from '#src/services/config.service'; import SettingsService from '#src/services/settings.service'; // Epg services -import EpgClientService from '#src/services/epg/epgClient.service'; -import EpgProviderService from '#src/services/epg/epgProvider.service'; -import ViewNexaEpgService from '#src/services/epg/viewNexaEpg.service'; -import JWEpgService from '#src/services/epg/jwEpg.service'; +import EpgService from '#src/services/epg/epg.service'; +import ViewNexaEpgService from '#src/services/epg/viewNexa.epg.service'; +import JWEpgService from '#src/services/epg/jw.epg.service'; import WatchHistoryController from '#src/stores/WatchHistoryController'; import CheckoutController from '#src/stores/CheckoutController'; @@ -24,6 +23,7 @@ import AccountController from '#src/stores/AccountController'; import ProfileController from '#src/stores/ProfileController'; import FavoritesController from '#src/stores/FavoritesController'; import AppController from '#src/stores/AppController'; +import EpgController from '#src/stores/EpgController'; // Integration interfaces import AccountService from '#src/services/account.service'; @@ -51,6 +51,10 @@ container.bind(GenericEntitlementService).toSelf(); container.bind(ApiService).toSelf(); container.bind(SettingsService).toSelf(); +// EPG services +container.bind(EpgService).to(JWEpgService); +container.bind(EpgService).to(ViewNexaEpgService); + // Common controllers container.bind(AppController).toSelf(); container.bind(WatchHistoryController).toSelf(); @@ -60,6 +64,7 @@ container.bind(FavoritesController).toSelf(); container.bind(AccountController).toSelf(); container.bind(CheckoutController).toSelf(); container.bind(ProfileController).toSelf(); +container.bind(EpgController).toSelf(); container.bind('INTEGRATION_TYPE').toDynamicValue((context) => { return context.container.get(AppController).getIntegrationType(); @@ -77,14 +82,3 @@ container.bind(AccountService).to(InplayerAccountService).whenTargetNamed(INTEGR container.bind(CheckoutService).to(InplayerCheckoutService).whenTargetNamed(INTEGRATION.JWP); container.bind(SubscriptionService).to(InplayerSubscriptionService).whenTargetNamed(INTEGRATION.JWP); container.bind(ProfileService).to(InplayerProfileService).whenTargetNamed(INTEGRATION.JWP); - -// EPG integration -container.bind(EpgClientService).toSelf(); -container - .bind(EpgProviderService) - .to(ViewNexaEpgService) - .when((request) => request.target.name.equals(EPG_TYPE.VIEW_NEXA)); -container - .bind(EpgProviderService) - .to(JWEpgService) - .when((request) => request.target.name.equals(EPG_TYPE.JW)); diff --git a/src/services/epg/epgProvider.service.ts b/src/services/epg/epg.service.ts similarity index 70% rename from src/services/epg/epgProvider.service.ts rename to src/services/epg/epg.service.ts index 0684ae127..1a7ee0546 100644 --- a/src/services/epg/epgProvider.service.ts +++ b/src/services/epg/epg.service.ts @@ -1,7 +1,13 @@ -import type { EpgProgram } from '#types/epg'; +import type { EpgProgram, EpgScheduleType } from '#types/epg'; import type { PlaylistItem } from '#types/playlist'; export default abstract class EpgProviderService { + readonly type: EpgScheduleType; + + protected constructor(type: EpgScheduleType) { + this.type = type; + } + /** * Fetch the schedule data for the given PlaylistItem */ diff --git a/src/services/epg/jwEpg.service.test.ts b/src/services/epg/jw.epg.service.test.ts similarity index 99% rename from src/services/epg/jwEpg.service.test.ts rename to src/services/epg/jw.epg.service.test.ts index 6aecf4278..f7cff7d82 100644 --- a/src/services/epg/jwEpg.service.test.ts +++ b/src/services/epg/jw.epg.service.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect } from 'vitest'; import { mockFetch, mockGet } from 'vi-fetch'; import { unregister } from 'timezone-mock'; -import JWEpgService from './jwEpg.service'; +import JWEpgService from './jw.epg.service'; import livePlaylistFixture from '#test/fixtures/livePlaylist.json'; import type { Playlist } from '#types/playlist'; diff --git a/src/services/epg/jwEpg.service.ts b/src/services/epg/jw.epg.service.ts similarity index 92% rename from src/services/epg/jwEpg.service.ts rename to src/services/epg/jw.epg.service.ts index e0e87b0d8..488cff5ff 100644 --- a/src/services/epg/jwEpg.service.ts +++ b/src/services/epg/jw.epg.service.ts @@ -2,12 +2,13 @@ import { array, object, string } from 'yup'; import { isValid } from 'date-fns'; import { injectable } from 'inversify'; -import EpgProviderService from './epgProvider.service'; +import EpgService from './epg.service'; import type { PlaylistItem } from '#types/playlist'; import { getDataOrThrow } from '#src/utils/api'; import { logDev } from '#src/utils/common'; import type { EpgProgram } from '#types/epg'; +import { EPG_TYPE } from '#src/config'; const AUTHENTICATION_HEADER = 'API-KEY'; @@ -29,7 +30,11 @@ const jwEpgProgramSchema = object().shape({ }); @injectable() -export default class JWEpgService extends EpgProviderService { +export default class JWEpgService extends EpgService { + constructor() { + super(EPG_TYPE.JWP); + } + transformProgram = async (data: unknown): Promise => { const program = await jwEpgProgramSchema.validate(data); const image = program.chapterPointCustomProperties?.find((item) => item.key === 'image')?.value || undefined; diff --git a/src/services/epg/viewNexa.service.test.ts b/src/services/epg/viewNexa.epg.service.test.ts similarity index 99% rename from src/services/epg/viewNexa.service.test.ts rename to src/services/epg/viewNexa.epg.service.test.ts index ad563ced7..0cb699780 100644 --- a/src/services/epg/viewNexa.service.test.ts +++ b/src/services/epg/viewNexa.epg.service.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect } from 'vitest'; import { mockFetch, mockGet } from 'vi-fetch'; import { unregister } from 'timezone-mock'; -import ViewNexaEpgService from './viewNexaEpg.service'; +import ViewNexaEpgService from './viewNexa.epg.service'; import viewNexaChannel from '#test/epg/viewNexaChannel.xml?raw'; import livePlaylistFixture from '#test/fixtures/livePlaylist.json'; diff --git a/src/services/epg/viewNexaEpg.service.ts b/src/services/epg/viewNexa.epg.service.ts similarity index 85% rename from src/services/epg/viewNexaEpg.service.ts rename to src/services/epg/viewNexa.epg.service.ts index 6e58b6601..6f70528e6 100644 --- a/src/services/epg/viewNexaEpg.service.ts +++ b/src/services/epg/viewNexa.epg.service.ts @@ -2,11 +2,12 @@ import { object, string } from 'yup'; import { parse } from 'date-fns'; import { injectable } from 'inversify'; -import EpgProviderService from './epgProvider.service'; +import EpgService from './epg.service'; import type { PlaylistItem } from '#types/playlist'; import { logDev } from '#src/utils/common'; import type { EpgProgram } from '#types/epg'; +import { EPG_TYPE } from '#src/config'; const viewNexaEpgProgramSchema = object().shape({ 'episode-num': object().shape({ @@ -28,7 +29,11 @@ const viewNexaEpgProgramSchema = object().shape({ const parseData = (date: string): string => parse(date, 'yyyyMdHms xxxx', new Date()).toISOString(); @injectable() -export default class ViewNexaEpgService extends EpgProviderService { +export default class ViewNexaEpgService extends EpgService { + constructor() { + super(EPG_TYPE.VIEW_NEXA); + } + /** * Validate the given data with the viewNexaProgramSchema and transform it into an EpgProgram */ @@ -38,11 +43,11 @@ export default class ViewNexaEpgService extends EpgProviderService { return { id: program['episode-num']['#text'], title: program['title']['#text'], - description: program['desc']['#text'], startTime: parseData(program['start']), endTime: parseData(program['stop']), - cardImage: program['icon']['src'], - backgroundImage: program['icon']['src'], + description: program?.['desc']?.['#text'], + cardImage: program?.['icon']?.['src'], + backgroundImage: program?.['icon']?.['src'], }; }; diff --git a/src/services/epg/epgClient.service.test.ts b/src/stores/EpgController.test.ts similarity index 78% rename from src/services/epg/epgClient.service.test.ts rename to src/stores/EpgController.test.ts index bff9dfaff..2293d8d84 100644 --- a/src/services/epg/epgClient.service.test.ts +++ b/src/stores/EpgController.test.ts @@ -1,8 +1,7 @@ import { afterEach, beforeEach, describe, expect } from 'vitest'; import { unregister } from 'timezone-mock'; -import EpgClientService from './epgClient.service'; -import type EpgProviderService from './epgProvider.service'; +import EpgController from './EpgController'; import channel1 from '#test/epg/jwChannel.json'; import livePlaylistFixture from '#test/fixtures/livePlaylist.json'; @@ -14,8 +13,18 @@ const livePlaylist = livePlaylistFixture as Playlist; const transformProgram = vi.fn(); const fetchSchedule = vi.fn(); -const epgProvider = { transformProgram, fetchSchedule }; -const epgService = new EpgClientService(epgProvider, epgProvider); +const epgService = { transformProgram, fetchSchedule }; +const jwpEpgService = { + ...epgService, + type: EPG_TYPE.JWP, +}; + +const viewNexaEpgService = { + ...epgService, + type: EPG_TYPE.VIEW_NEXA, +}; + +const epgController = new EpgController([jwpEpgService, viewNexaEpgService]); const mockProgram1 = { id: 'test', @@ -37,19 +46,6 @@ const mockProgram2 = { description: 'Description', }; -vi.mock('#src/modules/container', () => ({ - getNamedModule: (_service: EpgProviderService, type: string) => { - switch (type) { - case EPG_TYPE.JW: - case EPG_TYPE.VIEW_NEXA: - return { - transformProgram, - fetchSchedule, - }; - } - }, -})); - describe('epgService', () => { beforeEach(() => { vi.useFakeTimers(); @@ -76,7 +72,7 @@ describe('epgService', () => { fetchSchedule.mockResolvedValue(channel1); transformProgram.mockResolvedValue(mockProgram); - const schedule = await epgService.getSchedule(livePlaylist.playlist[0]); + const schedule = await epgController.getSchedule(livePlaylist.playlist[0]); expect(schedule.title).toEqual('Channel 1'); expect(schedule.programs.length).toEqual(33); @@ -94,7 +90,7 @@ describe('epgService', () => { const item = Object.assign({}, livePlaylist.playlist[0]); item.scheduleDemo = '1'; - const schedule = await epgService.getSchedule(item); + const schedule = await epgController.getSchedule(item); expect(schedule.title).toEqual('Channel 1'); expect(schedule.programs[0].startTime).toEqual('2036-06-03T23:50:00.000Z'); @@ -116,7 +112,7 @@ describe('epgService', () => { transformProgram.mockRejectedValueOnce(undefined); transformProgram.mockResolvedValueOnce(mockProgram2); - const schedule = await epgService.parseSchedule([scheduleItem, scheduleItem, scheduleItem, scheduleItem], livePlaylist.playlist[0]); + const schedule = await epgController.parseSchedule([scheduleItem, scheduleItem, scheduleItem, scheduleItem], livePlaylist.playlist[0]); expect(schedule.length).toEqual(2); }); diff --git a/src/services/epg/epgClient.service.ts b/src/stores/EpgController.ts similarity index 81% rename from src/services/epg/epgClient.service.ts rename to src/stores/EpgController.ts index 8be758e20..81ab05545 100644 --- a/src/services/epg/epgClient.service.ts +++ b/src/stores/EpgController.ts @@ -1,12 +1,11 @@ import { addDays, differenceInDays } from 'date-fns'; -import { injectable, targetName } from 'inversify'; - -import EpgProviderService from './epgProvider.service'; +import { injectable, multiInject } from 'inversify'; +import EpgService from '#src/services/epg/epg.service'; import { logDev } from '#src/utils/common'; -import { EPG_TYPE } from '#src/config'; import type { PlaylistItem } from '#types/playlist'; import type { EpgProgram, EpgChannel } from '#types/epg'; +import { EPG_TYPE } from '#src/config'; export const isFulfilled = (input: PromiseSettledResult): input is PromiseFulfilledResult => { if (input.status === 'fulfilled') { @@ -18,13 +17,11 @@ export const isFulfilled = (input: PromiseSettledResult): input is Promise }; @injectable() -export default class EpgClientService { - private viewNexaProvider: EpgProviderService; - private jwProvider: EpgProviderService; +export default class EpgController { + private epgServices: EpgService[]; - public constructor(@targetName(EPG_TYPE.JW) jwProvider: EpgProviderService, @targetName(EPG_TYPE.VIEW_NEXA) viewNexaProvider: EpgProviderService) { - this.viewNexaProvider = viewNexaProvider; - this.jwProvider = jwProvider; + public constructor(@multiInject(EpgService) epgServices: EpgService[]) { + this.epgServices = epgServices || []; } /** @@ -55,7 +52,7 @@ export default class EpgClientService { parseSchedule = async (data: unknown, item: PlaylistItem) => { const demo = !!item.scheduleDemo || false; - const epgService = this.getScheduleProvider(item); + const epgService = this.getEpgService(item); if (!Array.isArray(data)) return []; @@ -84,7 +81,7 @@ export default class EpgClientService { * When there is no program (empty schedule) or the request fails, it returns a static program. */ getSchedule = async (item: PlaylistItem) => { - const epgService = this.getScheduleProvider(item); + const epgService = this.getEpgService(item); const schedule = await epgService.fetchSchedule(item); const programs = await this.parseSchedule(schedule, item); @@ -102,8 +99,15 @@ export default class EpgClientService { } as EpgChannel; }; - getScheduleProvider = (item: PlaylistItem) => { - return item?.scheduleType === EPG_TYPE.VIEW_NEXA ? this.viewNexaProvider : this.jwProvider; + getEpgService = (item: PlaylistItem) => { + const scheduleType = item?.scheduleType || EPG_TYPE.JWP; + const service = this.epgServices.find((service) => service.type === scheduleType); + + if (!service) { + throw Error(`No epg service was added for the ${scheduleType} schedule type`); + } + + return service; }; /** diff --git a/test/fixtures/livePlaylist.json b/test/fixtures/livePlaylist.json index ae207aa61..d2ad91a51 100644 --- a/test/fixtures/livePlaylist.json +++ b/test/fixtures/livePlaylist.json @@ -50,7 +50,7 @@ "pubdate": 1615480440, "description": "A 24x7 airing of long films released by Blender Open Movie Project, including Elephants Dream, Big Buck Bunny, Sintel, Tears Of Steel, and Cosmos Laundromat.", "tags": "live", - "scheduleType": "JW", + "scheduleType": "JWP", "scheduleUrl": "/epg/jwChannel.json", "catchupHours": "7", "sources": [ diff --git a/types/epg.d.ts b/types/epg.d.ts index 54a6bf7c7..36ce09fbc 100644 --- a/types/epg.d.ts +++ b/types/epg.d.ts @@ -1,3 +1,5 @@ +import type { EPG_TYPE } from '#src/config'; + export type EpgChannel = { id: string; title: string; @@ -17,3 +19,5 @@ export type EpgProgram = { cardImage?: string; backgroundImage?: string; }; + +export type EpgScheduleType = keyof typeof EPG_TYPE; From 0bacf37998904278b46bca17d6029f054150fc51 Mon Sep 17 00:00:00 2001 From: olga-jwp Date: Wed, 20 Dec 2023 19:21:43 +0100 Subject: [PATCH 04/18] feat: add user_id and profile_id for CDN analytics --- src/components/Player/Player.tsx | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/components/Player/Player.tsx b/src/components/Player/Player.tsx index 249c4000a..4573788d9 100644 --- a/src/components/Player/Player.tsx +++ b/src/components/Player/Player.tsx @@ -11,6 +11,8 @@ import useOttAnalytics from '#src/hooks/useOttAnalytics'; import { logDev, testId } from '#src/utils/common'; import { useConfigStore } from '#src/stores/ConfigStore'; import type { AdSchedule } from '#types/ad-schedule'; +import { useAccountStore } from '#src/stores/AccountStore'; +import { useProfileStore } from '#src/stores/ProfileStore'; type Props = { feedId?: string; @@ -56,6 +58,11 @@ const Player: React.FC = ({ const loadingRef = useRef(false); const [libLoaded, setLibLoaded] = useState(!!window.jwplayer); const startTimeRef = useRef(startTime); + + const { config } = useConfigStore((s) => s); + const { user } = useAccountStore((s) => s); + const { profile } = useProfileStore(); + const setPlayer = useOttAnalytics(item, feedId); const { settings } = useConfigStore((s) => s); @@ -63,6 +70,10 @@ const Player: React.FC = ({ const playerId = settings.playerId; const playerLicenseKey = settings.playerLicenseKey; + const isJwIntegration = config?.integrations?.jwp; + const userId = user?.id; + const profileId = profile?.id; + const handleBeforePlay = useEventCallback(onBeforePlay); const handlePlay = useEventCallback(onPlay); const handlePause = useEventCallback(onPause); @@ -161,6 +172,23 @@ const Player: React.FC = ({ playerRef.current = window.jwplayer(playerElementRef.current) as JWPlayer; + // Add user_id and profile_id for CDN analytics + const { sources } = item; + + sources?.map((source) => { + const url = new URL(source.file); + const isVOD = url.pathname.includes('manifests'); + const isBCL = url.pathname.includes('broadcast'); + const isDRM = url.searchParams.has('exp') || url.searchParams.has('sig'); + + if ((isVOD || isBCL) && !isDRM && userId) { + url.searchParams.set('user_id', userId); + if (isJwIntegration && profileId) url.searchParams.append('profile_id', profileId); + } + + source.file = url.toString(); + }); + // Player options are untyped const playerOptions: { [key: string]: unknown } = { advertising: { @@ -213,7 +241,7 @@ const Player: React.FC = ({ if (libLoaded) { initializePlayer(); } - }, [libLoaded, item, detachEvents, attachEvents, playerId, setPlayer, autostart, adsData, playerLicenseKey, feedId]); + }, [libLoaded, item, detachEvents, attachEvents, playerId, setPlayer, autostart, adsData, playerLicenseKey, feedId, isJwIntegration, profileId, userId]); useEffect(() => { return () => { From 114150e6cdd8aaf436c06df30ea785f458b641f0 Mon Sep 17 00:00:00 2001 From: olga-jwp Date: Thu, 11 Jan 2024 18:05:56 +0100 Subject: [PATCH 05/18] feat: add user_id and profile_id for CDN analytics --- src/components/Player/Player.tsx | 22 +++++----------------- src/utils/player.ts | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 17 deletions(-) create mode 100644 src/utils/player.ts diff --git a/src/components/Player/Player.tsx b/src/components/Player/Player.tsx index 4573788d9..e062e8d10 100644 --- a/src/components/Player/Player.tsx +++ b/src/components/Player/Player.tsx @@ -13,6 +13,7 @@ import { useConfigStore } from '#src/stores/ConfigStore'; import type { AdSchedule } from '#types/ad-schedule'; import { useAccountStore } from '#src/stores/AccountStore'; import { useProfileStore } from '#src/stores/ProfileStore'; +import { attachAnalyticsParams } from '#src/utils/player'; type Props = { feedId?: string; @@ -70,7 +71,7 @@ const Player: React.FC = ({ const playerId = settings.playerId; const playerLicenseKey = settings.playerLicenseKey; - const isJwIntegration = config?.integrations?.jwp; + const isJwIntegration = !!config?.integrations?.jwp; const userId = user?.id; const profileId = profile?.id; @@ -172,22 +173,9 @@ const Player: React.FC = ({ playerRef.current = window.jwplayer(playerElementRef.current) as JWPlayer; - // Add user_id and profile_id for CDN analytics - const { sources } = item; - - sources?.map((source) => { - const url = new URL(source.file); - const isVOD = url.pathname.includes('manifests'); - const isBCL = url.pathname.includes('broadcast'); - const isDRM = url.searchParams.has('exp') || url.searchParams.has('sig'); - - if ((isVOD || isBCL) && !isDRM && userId) { - url.searchParams.set('user_id', userId); - if (isJwIntegration && profileId) url.searchParams.append('profile_id', profileId); - } - - source.file = url.toString(); - }); + // Inject user_id and profile_id into the CDN analytics + const { sources, mediaid } = item; + attachAnalyticsParams(sources, mediaid, isJwIntegration, userId, profileId); // Player options are untyped const playerOptions: { [key: string]: unknown } = { diff --git a/src/utils/player.ts b/src/utils/player.ts new file mode 100644 index 000000000..d9faf4378 --- /dev/null +++ b/src/utils/player.ts @@ -0,0 +1,21 @@ +import type { Source } from '#types/playlist'; + +export const attachAnalyticsParams = (sources: Source[], mediaid: string, isJwIntegration: boolean, userId?: string, profileId?: string) => { + return sources.map((source: Source) => { + const url = new URL(source.file); + + // Attach user_id and profile_id only for VOD and BCL SaaS Live Streams + const isVOD = url.href === `https://cdn.jwplayer.com/manifests/${mediaid}.m3u8`; + const isBCL = url.href === `https://content.jwplatform.com/live/broadcast/${mediaid}.m3u8`; + + if ((isVOD || isBCL) && userId) { + url.searchParams.set('user_id', userId); + + if (isJwIntegration && profileId) { + url.searchParams.append('profile_id', profileId); + } + } + + source.file = url.toString(); + }); +}; From 0394daf6ce8d73103aee5f5667a98fa089325569 Mon Sep 17 00:00:00 2001 From: Anton Lantukh Date: Fri, 12 Jan 2024 14:32:45 +0100 Subject: [PATCH 06/18] feat(epg): use getNamedModule --- src/modules/register.ts | 10 +++++----- src/services/epg/epg.service.ts | 10 ++-------- src/services/epg/jw.epg.service.ts | 5 ----- src/services/epg/viewNexa.epg.service.ts | 11 ----------- src/stores/EpgController.test.ts | 22 ++++++++++------------ src/stores/EpgController.ts | 11 +++-------- 6 files changed, 20 insertions(+), 49 deletions(-) diff --git a/src/modules/register.ts b/src/modules/register.ts index 581ddd8c7..6981ef9dd 100644 --- a/src/modules/register.ts +++ b/src/modules/register.ts @@ -1,7 +1,7 @@ // To organize imports in a better way /* eslint-disable import/order */ import 'reflect-metadata'; // include once in the app for inversify (see: https://github.com/inversify/InversifyJS/blob/master/README.md#-installation) -import { INTEGRATION } from '#src/config'; +import { EPG_TYPE, INTEGRATION } from '#src/config'; import { container } from '#src/modules/container'; import ApiService from '#src/services/api.service'; @@ -51,10 +51,6 @@ container.bind(GenericEntitlementService).toSelf(); container.bind(ApiService).toSelf(); container.bind(SettingsService).toSelf(); -// EPG services -container.bind(EpgService).to(JWEpgService); -container.bind(EpgService).to(ViewNexaEpgService); - // Common controllers container.bind(AppController).toSelf(); container.bind(WatchHistoryController).toSelf(); @@ -70,6 +66,10 @@ container.bind('INTEGRATION_TYPE').toDynamicValue((context) => { return context.container.get(AppController).getIntegrationType(); }); +// EPG services +container.bind(EpgService).to(JWEpgService).whenTargetNamed(EPG_TYPE.JWP); +container.bind(EpgService).to(ViewNexaEpgService).whenTargetNamed(EPG_TYPE.VIEW_NEXA); + // Cleeng integration container.bind(CleengService).toSelf(); container.bind(AccountService).to(CleengAccountService).whenTargetNamed(INTEGRATION.CLEENG); diff --git a/src/services/epg/epg.service.ts b/src/services/epg/epg.service.ts index 1a7ee0546..bb19d17de 100644 --- a/src/services/epg/epg.service.ts +++ b/src/services/epg/epg.service.ts @@ -1,13 +1,7 @@ -import type { EpgProgram, EpgScheduleType } from '#types/epg'; +import type { EpgProgram } from '#types/epg'; import type { PlaylistItem } from '#types/playlist'; -export default abstract class EpgProviderService { - readonly type: EpgScheduleType; - - protected constructor(type: EpgScheduleType) { - this.type = type; - } - +export default abstract class EpgService { /** * Fetch the schedule data for the given PlaylistItem */ diff --git a/src/services/epg/jw.epg.service.ts b/src/services/epg/jw.epg.service.ts index 488cff5ff..1066e424f 100644 --- a/src/services/epg/jw.epg.service.ts +++ b/src/services/epg/jw.epg.service.ts @@ -8,7 +8,6 @@ import type { PlaylistItem } from '#types/playlist'; import { getDataOrThrow } from '#src/utils/api'; import { logDev } from '#src/utils/common'; import type { EpgProgram } from '#types/epg'; -import { EPG_TYPE } from '#src/config'; const AUTHENTICATION_HEADER = 'API-KEY'; @@ -31,10 +30,6 @@ const jwEpgProgramSchema = object().shape({ @injectable() export default class JWEpgService extends EpgService { - constructor() { - super(EPG_TYPE.JWP); - } - transformProgram = async (data: unknown): Promise => { const program = await jwEpgProgramSchema.validate(data); const image = program.chapterPointCustomProperties?.find((item) => item.key === 'image')?.value || undefined; diff --git a/src/services/epg/viewNexa.epg.service.ts b/src/services/epg/viewNexa.epg.service.ts index 6f70528e6..cc8142de6 100644 --- a/src/services/epg/viewNexa.epg.service.ts +++ b/src/services/epg/viewNexa.epg.service.ts @@ -7,7 +7,6 @@ import EpgService from './epg.service'; import type { PlaylistItem } from '#types/playlist'; import { logDev } from '#src/utils/common'; import type { EpgProgram } from '#types/epg'; -import { EPG_TYPE } from '#src/config'; const viewNexaEpgProgramSchema = object().shape({ 'episode-num': object().shape({ @@ -30,13 +29,6 @@ const parseData = (date: string): string => parse(date, 'yyyyMdHms xxxx', new Da @injectable() export default class ViewNexaEpgService extends EpgService { - constructor() { - super(EPG_TYPE.VIEW_NEXA); - } - - /** - * Validate the given data with the viewNexaProgramSchema and transform it into an EpgProgram - */ transformProgram = async (data: unknown): Promise => { const program = await viewNexaEpgProgramSchema.validate(data); @@ -51,9 +43,6 @@ export default class ViewNexaEpgService extends EpgService { }; }; - /** - * Fetch the schedule data for the given PlaylistItem - */ fetchSchedule = async (item: PlaylistItem) => { const { XMLParser } = await import('fast-xml-parser'); diff --git a/src/stores/EpgController.test.ts b/src/stores/EpgController.test.ts index 2293d8d84..704add4f4 100644 --- a/src/stores/EpgController.test.ts +++ b/src/stores/EpgController.test.ts @@ -5,26 +5,24 @@ import EpgController from './EpgController'; import channel1 from '#test/epg/jwChannel.json'; import livePlaylistFixture from '#test/fixtures/livePlaylist.json'; +import EpgService from '#src/services/epg/epg.service'; import type { Playlist } from '#types/playlist'; -import { EPG_TYPE } from '#src/config'; const livePlaylist = livePlaylistFixture as Playlist; const transformProgram = vi.fn(); const fetchSchedule = vi.fn(); -const epgService = { transformProgram, fetchSchedule }; -const jwpEpgService = { - ...epgService, - type: EPG_TYPE.JWP, -}; - -const viewNexaEpgService = { - ...epgService, - type: EPG_TYPE.VIEW_NEXA, -}; +vi.mock('#src/modules/container', () => ({ + getNamedModule: (type: typeof EpgService) => { + switch (type) { + case EpgService: + return { transformProgram, fetchSchedule }; + } + }, +})); -const epgController = new EpgController([jwpEpgService, viewNexaEpgService]); +const epgController = new EpgController(); const mockProgram1 = { id: 'test', diff --git a/src/stores/EpgController.ts b/src/stores/EpgController.ts index 81ab05545..19a97d621 100644 --- a/src/stores/EpgController.ts +++ b/src/stores/EpgController.ts @@ -1,11 +1,12 @@ import { addDays, differenceInDays } from 'date-fns'; -import { injectable, multiInject } from 'inversify'; +import { injectable } from 'inversify'; import EpgService from '#src/services/epg/epg.service'; import { logDev } from '#src/utils/common'; import type { PlaylistItem } from '#types/playlist'; import type { EpgProgram, EpgChannel } from '#types/epg'; import { EPG_TYPE } from '#src/config'; +import { getNamedModule } from '#src/modules/container'; export const isFulfilled = (input: PromiseSettledResult): input is PromiseFulfilledResult => { if (input.status === 'fulfilled') { @@ -18,12 +19,6 @@ export const isFulfilled = (input: PromiseSettledResult): input is Promise @injectable() export default class EpgController { - private epgServices: EpgService[]; - - public constructor(@multiInject(EpgService) epgServices: EpgService[]) { - this.epgServices = epgServices || []; - } - /** * Update the start and end time properties of the given programs with the current date. * This can be used when having a static schedule or while developing @@ -101,7 +96,7 @@ export default class EpgController { getEpgService = (item: PlaylistItem) => { const scheduleType = item?.scheduleType || EPG_TYPE.JWP; - const service = this.epgServices.find((service) => service.type === scheduleType); + const service = getNamedModule(EpgService, scheduleType); if (!service) { throw Error(`No epg service was added for the ${scheduleType} schedule type`); From 43c487ca72bfdbf5977e6f873f3505fe5bf6011a Mon Sep 17 00:00:00 2001 From: Anton Lantukh Date: Fri, 12 Jan 2024 14:40:55 +0100 Subject: [PATCH 07/18] feat(epg): fix live channel casing --- src/config.ts | 2 +- src/pages/ScreenRouting/MediaScreenRouter.tsx | 2 +- src/utils/media.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config.ts b/src/config.ts index c44f024b1..4eb918deb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -42,7 +42,7 @@ export const CONTENT_TYPE = { // Page with a list of channels live: 'live', // Separate channel page - livechannel: 'livechannel', + liveChannel: 'liveChannel', // Static page with markdown page: 'page', // Page with shelves list diff --git a/src/pages/ScreenRouting/MediaScreenRouter.tsx b/src/pages/ScreenRouting/MediaScreenRouter.tsx index cc8c6c4a3..b99942204 100644 --- a/src/pages/ScreenRouting/MediaScreenRouter.tsx +++ b/src/pages/ScreenRouting/MediaScreenRouter.tsx @@ -21,7 +21,7 @@ export const mediaScreenMap = new ScreenMap(); // Register media screens mediaScreenMap.registerByContentType(MediaSeries, CONTENT_TYPE.series); mediaScreenMap.registerByContentType(MediaEpisode, CONTENT_TYPE.episode); -mediaScreenMap.registerByContentType(MediaLiveChannel, CONTENT_TYPE.livechannel); +mediaScreenMap.registerByContentType(MediaLiveChannel, CONTENT_TYPE.liveChannel); mediaScreenMap.registerByContentType(MediaStaticPage, CONTENT_TYPE.page); mediaScreenMap.registerDefault(MediaMovie); diff --git a/src/utils/media.ts b/src/utils/media.ts index b7cffcece..3596bac80 100644 --- a/src/utils/media.ts +++ b/src/utils/media.ts @@ -48,4 +48,4 @@ export const getLegacySeriesPlaylistIdFromEpisodeTags = (item: PlaylistItem | un }; export const isLiveChannel = (item: PlaylistItem): item is RequiredProperties => - item.contentType?.toLowerCase() === CONTENT_TYPE.livechannel && !!item.liveChannelsId; + item.contentType?.toLowerCase() === CONTENT_TYPE.liveChannel && !!item.liveChannelsId; From d92937da7592c580df8230a4c1ee2fad3765ff55 Mon Sep 17 00:00:00 2001 From: olga-jwp Date: Fri, 12 Jan 2024 16:26:20 +0100 Subject: [PATCH 08/18] feat: cdn analytics code cleanup --- src/components/Player/Player.tsx | 5 ++--- src/utils/{player.ts => analytics.ts} | 8 +++++--- 2 files changed, 7 insertions(+), 6 deletions(-) rename src/utils/{player.ts => analytics.ts} (65%) diff --git a/src/components/Player/Player.tsx b/src/components/Player/Player.tsx index e062e8d10..98efda785 100644 --- a/src/components/Player/Player.tsx +++ b/src/components/Player/Player.tsx @@ -13,7 +13,7 @@ import { useConfigStore } from '#src/stores/ConfigStore'; import type { AdSchedule } from '#types/ad-schedule'; import { useAccountStore } from '#src/stores/AccountStore'; import { useProfileStore } from '#src/stores/ProfileStore'; -import { attachAnalyticsParams } from '#src/utils/player'; +import { attachAnalyticsParams } from '#src/utils/analytics'; type Props = { feedId?: string; @@ -174,8 +174,7 @@ const Player: React.FC = ({ playerRef.current = window.jwplayer(playerElementRef.current) as JWPlayer; // Inject user_id and profile_id into the CDN analytics - const { sources, mediaid } = item; - attachAnalyticsParams(sources, mediaid, isJwIntegration, userId, profileId); + attachAnalyticsParams(item, isJwIntegration, userId, profileId); // Player options are untyped const playerOptions: { [key: string]: unknown } = { diff --git a/src/utils/player.ts b/src/utils/analytics.ts similarity index 65% rename from src/utils/player.ts rename to src/utils/analytics.ts index d9faf4378..0b46edb5e 100644 --- a/src/utils/player.ts +++ b/src/utils/analytics.ts @@ -1,6 +1,8 @@ -import type { Source } from '#types/playlist'; +import type { PlaylistItem, Source } from '#types/playlist'; + +export const attachAnalyticsParams = (item: PlaylistItem, isJwIntegration: boolean, userId?: string, profileId?: string) => { + const { sources, mediaid } = item; -export const attachAnalyticsParams = (sources: Source[], mediaid: string, isJwIntegration: boolean, userId?: string, profileId?: string) => { return sources.map((source: Source) => { const url = new URL(source.file); @@ -12,7 +14,7 @@ export const attachAnalyticsParams = (sources: Source[], mediaid: string, isJwIn url.searchParams.set('user_id', userId); if (isJwIntegration && profileId) { - url.searchParams.append('profile_id', profileId); + url.searchParams.set('profile_id', profileId); } } From 68d31c46df3b5999f8bee79ed269e597b30993bf Mon Sep 17 00:00:00 2001 From: olga-jwp Date: Fri, 12 Jan 2024 17:33:59 +0100 Subject: [PATCH 09/18] fix: transform url to lowerCase --- src/utils/analytics.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 0b46edb5e..3deaa69c1 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -6,9 +6,12 @@ export const attachAnalyticsParams = (item: PlaylistItem, isJwIntegration: boole return sources.map((source: Source) => { const url = new URL(source.file); + const mediaId = mediaid.toLowerCase(); + const sourceUrl = url.href.toLowerCase(); + // Attach user_id and profile_id only for VOD and BCL SaaS Live Streams - const isVOD = url.href === `https://cdn.jwplayer.com/manifests/${mediaid}.m3u8`; - const isBCL = url.href === `https://content.jwplatform.com/live/broadcast/${mediaid}.m3u8`; + const isVOD = sourceUrl === `https://cdn.jwplayer.com/manifests/${mediaId}.m3u8`; + const isBCL = sourceUrl === `https://content.jwplatform.com/live/broadcast/${mediaId}.m3u8`; if ((isVOD || isBCL) && userId) { url.searchParams.set('user_id', userId); From aeef40f6f375783cd0820fd27154b616570d3cf0 Mon Sep 17 00:00:00 2001 From: olga-jwp Date: Fri, 12 Jan 2024 17:45:24 +0100 Subject: [PATCH 10/18] feat: cdn analytics code cleanup --- src/components/Player/Player.tsx | 14 ++------------ src/utils/analytics.ts | 13 ++++++++++++- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/components/Player/Player.tsx b/src/components/Player/Player.tsx index 98efda785..a8e51fbf9 100644 --- a/src/components/Player/Player.tsx +++ b/src/components/Player/Player.tsx @@ -11,8 +11,6 @@ import useOttAnalytics from '#src/hooks/useOttAnalytics'; import { logDev, testId } from '#src/utils/common'; import { useConfigStore } from '#src/stores/ConfigStore'; import type { AdSchedule } from '#types/ad-schedule'; -import { useAccountStore } from '#src/stores/AccountStore'; -import { useProfileStore } from '#src/stores/ProfileStore'; import { attachAnalyticsParams } from '#src/utils/analytics'; type Props = { @@ -60,10 +58,6 @@ const Player: React.FC = ({ const [libLoaded, setLibLoaded] = useState(!!window.jwplayer); const startTimeRef = useRef(startTime); - const { config } = useConfigStore((s) => s); - const { user } = useAccountStore((s) => s); - const { profile } = useProfileStore(); - const setPlayer = useOttAnalytics(item, feedId); const { settings } = useConfigStore((s) => s); @@ -71,10 +65,6 @@ const Player: React.FC = ({ const playerId = settings.playerId; const playerLicenseKey = settings.playerLicenseKey; - const isJwIntegration = !!config?.integrations?.jwp; - const userId = user?.id; - const profileId = profile?.id; - const handleBeforePlay = useEventCallback(onBeforePlay); const handlePlay = useEventCallback(onPlay); const handlePause = useEventCallback(onPause); @@ -174,7 +164,7 @@ const Player: React.FC = ({ playerRef.current = window.jwplayer(playerElementRef.current) as JWPlayer; // Inject user_id and profile_id into the CDN analytics - attachAnalyticsParams(item, isJwIntegration, userId, profileId); + attachAnalyticsParams(item); // Player options are untyped const playerOptions: { [key: string]: unknown } = { @@ -228,7 +218,7 @@ const Player: React.FC = ({ if (libLoaded) { initializePlayer(); } - }, [libLoaded, item, detachEvents, attachEvents, playerId, setPlayer, autostart, adsData, playerLicenseKey, feedId, isJwIntegration, profileId, userId]); + }, [libLoaded, item, detachEvents, attachEvents, playerId, setPlayer, autostart, adsData, playerLicenseKey, feedId]); useEffect(() => { return () => { diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 3deaa69c1..11501f7ee 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -1,8 +1,19 @@ +import { useAccountStore } from '#src/stores/AccountStore'; +import { useConfigStore } from '#src/stores/ConfigStore'; +import { useProfileStore } from '#src/stores/ProfileStore'; import type { PlaylistItem, Source } from '#types/playlist'; -export const attachAnalyticsParams = (item: PlaylistItem, isJwIntegration: boolean, userId?: string, profileId?: string) => { +export const attachAnalyticsParams = (item: PlaylistItem) => { + const { config } = useConfigStore.getState(); + const { user } = useAccountStore.getState(); + const { profile } = useProfileStore.getState(); + const { sources, mediaid } = item; + const userId = user?.id; + const profileId = profile?.id; + const isJwIntegration = !!config?.integrations?.jwp; + return sources.map((source: Source) => { const url = new URL(source.file); From ce329084bf8565c475ea37e04006aaa08766c255 Mon Sep 17 00:00:00 2001 From: Kire Mitrov Date: Mon, 15 Jan 2024 16:35:28 +0100 Subject: [PATCH 11/18] fix: missing getState on useConfigStore under updateCardDetails (#433) --- src/stores/AccountController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/AccountController.ts b/src/stores/AccountController.ts index fdc503b89..7ac5e6a1a 100644 --- a/src/stores/AccountController.ts +++ b/src/stores/AccountController.ts @@ -412,7 +412,7 @@ export default class AccountController { expYear: number; currency: string; }) => { - const { isSandbox } = useConfigStore(); + const { isSandbox } = useConfigStore.getState(); const { getAccountInfo } = useAccountStore.getState(); const { customerId } = getAccountInfo(); From be774d47780a238800796caf22700c3e4ce7c28f Mon Sep 17 00:00:00 2001 From: Anton Lantukh Date: Fri, 12 Jan 2024 16:59:02 +0100 Subject: [PATCH 12/18] fix(epg): check lower case, log error --- src/config.ts | 6 +++--- src/modules/register.ts | 4 ++-- src/services/epg/viewNexa.epg.service.test.ts | 4 ++-- src/stores/EpgController.ts | 6 +++--- test/fixtures/livePlaylist.json | 8 ++++---- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/config.ts b/src/config.ts index 4eb918deb..609e7d387 100644 --- a/src/config.ts +++ b/src/config.ts @@ -42,7 +42,7 @@ export const CONTENT_TYPE = { // Page with a list of channels live: 'live', // Separate channel page - liveChannel: 'liveChannel', + liveChannel: 'livechannel', // Static page with markdown page: 'page', // Page with shelves list @@ -75,6 +75,6 @@ export const DEFAULT_FEATURES = { }; export const EPG_TYPE = { - JWP: 'JWP', - VIEW_NEXA: 'VIEW_NEXA', + jwp: 'jwp', + viewNexa: 'viewnexa', } as const; diff --git a/src/modules/register.ts b/src/modules/register.ts index 6981ef9dd..c614a0837 100644 --- a/src/modules/register.ts +++ b/src/modules/register.ts @@ -67,8 +67,8 @@ container.bind('INTEGRATION_TYPE').toDynamicValue((context) => { }); // EPG services -container.bind(EpgService).to(JWEpgService).whenTargetNamed(EPG_TYPE.JWP); -container.bind(EpgService).to(ViewNexaEpgService).whenTargetNamed(EPG_TYPE.VIEW_NEXA); +container.bind(EpgService).to(JWEpgService).whenTargetNamed(EPG_TYPE.jwp); +container.bind(EpgService).to(ViewNexaEpgService).whenTargetNamed(EPG_TYPE.viewNexa); // Cleeng integration container.bind(CleengService).toSelf(); diff --git a/src/services/epg/viewNexa.epg.service.test.ts b/src/services/epg/viewNexa.epg.service.test.ts index 0cb699780..c71c4c2fa 100644 --- a/src/services/epg/viewNexa.epg.service.test.ts +++ b/src/services/epg/viewNexa.epg.service.test.ts @@ -28,7 +28,7 @@ describe('ViewNexaEpgService', () => { test('fetchSchedule performs a request', async () => { const mock = mockGet('/epg/viewNexaChannel.xml').willResolveOnce([]); - const data = await epgService.fetchSchedule({ ...livePlaylist.playlist[0], scheduleUrl: '/epg/viewNexaChannel.xml', scheduleType: EPG_TYPE.VIEW_NEXA }); + const data = await epgService.fetchSchedule({ ...livePlaylist.playlist[0], scheduleUrl: '/epg/viewNexaChannel.xml', scheduleType: EPG_TYPE.viewNexa }); expect(mock).toHaveFetched(); expect(data).toEqual([]); @@ -36,7 +36,7 @@ describe('ViewNexaEpgService', () => { test('fetchSchedule parses xml content', async () => { const mock = mockGet('/epg/viewNexaChannel.xml').willResolveOnce(viewNexaChannel); - const data = await epgService.fetchSchedule({ ...livePlaylist.playlist[0], scheduleUrl: '/epg/viewNexaChannel.xml', scheduleType: EPG_TYPE.VIEW_NEXA }); + const data = await epgService.fetchSchedule({ ...livePlaylist.playlist[0], scheduleUrl: '/epg/viewNexaChannel.xml', scheduleType: EPG_TYPE.viewNexa }); expect(mock).toHaveFetched(); expect(data[0]).toEqual({ diff --git a/src/stores/EpgController.ts b/src/stores/EpgController.ts index 19a97d621..bc1783437 100644 --- a/src/stores/EpgController.ts +++ b/src/stores/EpgController.ts @@ -95,11 +95,11 @@ export default class EpgController { }; getEpgService = (item: PlaylistItem) => { - const scheduleType = item?.scheduleType || EPG_TYPE.JWP; - const service = getNamedModule(EpgService, scheduleType); + const scheduleType = item?.scheduleType || EPG_TYPE.jwp; + const service = getNamedModule(EpgService, scheduleType?.toLowerCase()); if (!service) { - throw Error(`No epg service was added for the ${scheduleType} schedule type`); + console.error(`No epg service was added for the ${scheduleType} schedule type`); } return service; diff --git a/test/fixtures/livePlaylist.json b/test/fixtures/livePlaylist.json index d2ad91a51..1cfecd5f8 100644 --- a/test/fixtures/livePlaylist.json +++ b/test/fixtures/livePlaylist.json @@ -50,7 +50,7 @@ "pubdate": 1615480440, "description": "A 24x7 airing of long films released by Blender Open Movie Project, including Elephants Dream, Big Buck Bunny, Sintel, Tears Of Steel, and Cosmos Laundromat.", "tags": "live", - "scheduleType": "JWP", + "scheduleType": "jwp", "scheduleUrl": "/epg/jwChannel.json", "catchupHours": "7", "sources": [ @@ -104,7 +104,7 @@ "pubdate": 1615480440, "description": "A 24x7 airing of long films released by Blender Open Movie Project, including Elephants Dream, Big Buck Bunny, Sintel, Tears Of Steel, and Cosmos Laundromat.", "tags": "live", - "scheduleType": "JW", + "scheduleType": "jwp", "scheduleUrl": "/epg/jwChannel.json", "sources": [ { @@ -157,7 +157,7 @@ "pubdate": 1615480440, "description": "A 24x7 airing of long films released by Blender Open Movie Project, including Elephants Dream, Big Buck Bunny, Sintel, Tears Of Steel, and Cosmos Laundromat.", "tags": "live", - "scheduleType": "JW", + "scheduleType": "jwp", "scheduleUrl": "/epg/does-not-exist.json", "sources": [ { @@ -210,7 +210,7 @@ "pubdate": 1615480440, "description": "A 24x7 airing of long films released by Blender Open Movie Project, including Elephants Dream, Big Buck Bunny, Sintel, Tears Of Steel, and Cosmos Laundromat.", "tags": "live", - "scheduleType": "JW", + "scheduleType": "jwp", "scheduleUrl": "/epg/network-error.json", "sources": [ { From ff3b2bcfc3c237df0973d57acb9f4f3757ed602d Mon Sep 17 00:00:00 2001 From: borkopetrovicc <104987342+borkopetrovicc@users.noreply.github.com> Date: Tue, 16 Jan 2024 16:59:49 +0100 Subject: [PATCH 13/18] Chore: Viewers are able to subvert the Simultaneous login limit (#419) * fix: logout behavior in useNotifications hook * fix: added try and await --- src/hooks/useNotifications.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 834abcc52..0415cd6e0 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -60,10 +60,12 @@ export default async function useNotifications(uuid: string = '') { window.location.href = notification.resource?.redirect_to_url; break; case NotificationsTypes.ACCOUNT_LOGOUT: - if (notification.resource?.reason === 'sessions_limit') { - navigate(addQueryParams(window.location.pathname, { u: 'login', message: simultaneousLoginWarningKey })); - } else { - await accountController.logout(); + try { + await accountController?.logout(); + } finally { + if (notification.resource?.reason === 'sessions_limit') { + navigate(addQueryParams(window.location.pathname, { u: 'login', message: simultaneousLoginWarningKey })); + } } break; default: From 6281640fc4fb0f6f7b3b56cc34749465348cc7a2 Mon Sep 17 00:00:00 2001 From: mirovladimitrovski Date: Wed, 17 Jan 2024 13:26:07 +0100 Subject: [PATCH 14/18] fix: place consents in appropriate section --- src/components/Account/Account.tsx | 2 +- .../__snapshots__/Account.test.tsx.snap | 48 +++++-------------- 2 files changed, 13 insertions(+), 37 deletions(-) diff --git a/src/components/Account/Account.tsx b/src/components/Account/Account.tsx index 5342090d0..32af8496b 100644 --- a/src/components/Account/Account.tsx +++ b/src/components/Account/Account.tsx @@ -78,7 +78,7 @@ const Account = ({ panelClassName, panelHeaderClassName, canUpdateEmail = true } const nonTerms: Consent[] = []; publisherConsents?.forEach((consent) => { - if (consent?.type === 'checkbox') { + if (!consent?.type || consent?.type === 'checkbox') { terms.push(consent); } else { nonTerms.push(consent); diff --git a/src/components/Account/__snapshots__/Account.test.tsx.snap b/src/components/Account/__snapshots__/Account.test.tsx.snap index 57fdb4bda..2a20a13d7 100644 --- a/src/components/Account/__snapshots__/Account.test.tsx.snap +++ b/src/components/Account/__snapshots__/Account.test.tsx.snap @@ -134,51 +134,27 @@ exports[` > renders and matches snapshot 1`] = ` account.terms_and_tracking -
-
-
-
-
-

- account.other_registration_details -

-
-
+
+ Receive Marketing Emails +
From ba1e0de5b60c8a5bfed1e52bacaf15d977b69894 Mon Sep 17 00:00:00 2001 From: mirovladimitrovski Date: Wed, 17 Jan 2024 14:10:28 +0100 Subject: [PATCH 15/18] fix: fix e2e test --- test-e2e/tests/account_test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-e2e/tests/account_test.ts b/test-e2e/tests/account_test.ts index d5979e09b..519575898 100644 --- a/test-e2e/tests/account_test.ts +++ b/test-e2e/tests/account_test.ts @@ -253,7 +253,7 @@ function runTestSuite(config: typeof testConfigs.svod, providerName: string, res Scenario(`I can update my consents - ${providerName}`, async ({ I }) => { I.amOnPage(constants.accountsUrl); I.waitForText('Profile info', longTimeout); - I.scrollTo('//*[text() = "Other registration details"]'); + I.scrollTo('//*[text() = "Legal & Marketing"]', undefined, -100); I.dontSeeCheckboxIsChecked(consentCheckbox); I.dontSee('Save'); From e1003844da97ba97618ed46e9eba076e49a6159c Mon Sep 17 00:00:00 2001 From: Anton Lantukh Date: Fri, 12 Jan 2024 13:20:58 +0100 Subject: [PATCH 16/18] feat(project): change content type default schemas - use 'live_bcl' for streams - remove sections from 'episode' schema - update 'hub' schema - update 'liveChannel' and 'liveEvent' schemas - update `trailer` schema --- scripts/content-types/content-types.json | 242 +++++++----------- src/config.ts | 2 +- src/pages/ScreenRouting/MediaScreenRouter.tsx | 2 +- 3 files changed, 91 insertions(+), 155 deletions(-) diff --git a/scripts/content-types/content-types.json b/scripts/content-types/content-types.json index 2162962f5..328ad4f80 100644 --- a/scripts/content-types/content-types.json +++ b/scripts/content-types/content-types.json @@ -8,47 +8,47 @@ "field_type": "select", "placeholder": "Select a genre", "options": [ - {"value": "Action", "label": "Action"}, - {"value": "Thriller", "label": "Thriller"}, - {"value": "Horror", "label": "Horror"}, - {"value": "Drama", "label": "Drama"}, - {"value": "Romance", "label": "Romance"}, - {"value": "Western", "label": "Western"}, - {"value": "Comedy", "label": "Comedy"}, - {"value": "Science fiction", "label": "Science fiction"}, - {"value": "Adventure", "label": "Adventure"}, - {"value": "Music", "label": "Music"}, - {"value": "Animation", "label": "Animation"}, - {"value": "Crime film", "label": "Crime film"}, - {"value": "History", "label": "History"}, - {"value": "Musical genre", "label": "Musical genre"}, - {"value": "Narrative", "label": "Narrative"}, - {"value": "Documentary", "label": "Documentary"}, - {"value": "Mystery", "label": "Mystery"}, - {"value": "Noir", "label": "Noir"}, - {"value": "Fantasy", "label": "Fantasy"}, - {"value": "Romantic comedy", "label": "Romantic comedy"}, - {"value": "Musical", "label": "Musical"}, - {"value": "War", "label": "War"}, - {"value": "Television", "label": "Television"}, - {"value": "Fiction", "label": "Fiction"}, - {"value": "Historical drama", "label": "Historical drama"}, - {"value": "Sports", "label": "Sports"}, - {"value": "Epic", "label": "Epic"}, - {"value": "Thriller", "label": "Thriller"}, - {"value": "Disaster", "label": "Disaster"}, - {"value": "Martial Arts", "label": "Martial Arts"}, - {"value": "Hindi cinema", "label": "Hindi cinema"}, - {"value": "Satire", "label": "Satire"}, - {"value": "Experimental", "label": "Experimental"}, - {"value": "Slasher", "label": "Slasher"}, - {"value": "Short", "label": "Short"}, - {"value": "Biographical", "label": "Biographical"}, - {"value": "Animated film", "label": "Animated film"}, - {"value": "Narrative", "label": "Narrative"}, - {"value": "Educational", "label": "Educational"}, - {"value": "Cult film", "label": "Cult film"}, - {"value": "Action/Adventure", "label": "Action/Adventure"} + { "value": "Action", "label": "Action" }, + { "value": "Thriller", "label": "Thriller" }, + { "value": "Horror", "label": "Horror" }, + { "value": "Drama", "label": "Drama" }, + { "value": "Romance", "label": "Romance" }, + { "value": "Western", "label": "Western" }, + { "value": "Comedy", "label": "Comedy" }, + { "value": "Science fiction", "label": "Science fiction" }, + { "value": "Adventure", "label": "Adventure" }, + { "value": "Music", "label": "Music" }, + { "value": "Animation", "label": "Animation" }, + { "value": "Crime film", "label": "Crime film" }, + { "value": "History", "label": "History" }, + { "value": "Musical genre", "label": "Musical genre" }, + { "value": "Narrative", "label": "Narrative" }, + { "value": "Documentary", "label": "Documentary" }, + { "value": "Mystery", "label": "Mystery" }, + { "value": "Noir", "label": "Noir" }, + { "value": "Fantasy", "label": "Fantasy" }, + { "value": "Romantic comedy", "label": "Romantic comedy" }, + { "value": "Musical", "label": "Musical" }, + { "value": "War", "label": "War" }, + { "value": "Television", "label": "Television" }, + { "value": "Fiction", "label": "Fiction" }, + { "value": "Historical drama", "label": "Historical drama" }, + { "value": "Sports", "label": "Sports" }, + { "value": "Epic", "label": "Epic" }, + { "value": "Thriller", "label": "Thriller" }, + { "value": "Disaster", "label": "Disaster" }, + { "value": "Martial Arts", "label": "Martial Arts" }, + { "value": "Hindi cinema", "label": "Hindi cinema" }, + { "value": "Satire", "label": "Satire" }, + { "value": "Experimental", "label": "Experimental" }, + { "value": "Slasher", "label": "Slasher" }, + { "value": "Short", "label": "Short" }, + { "value": "Biographical", "label": "Biographical" }, + { "value": "Animated film", "label": "Animated film" }, + { "value": "Narrative", "label": "Narrative" }, + { "value": "Educational", "label": "Educational" }, + { "value": "Cult film", "label": "Cult film" }, + { "value": "Action/Adventure", "label": "Action/Adventure" } ] } }, @@ -79,7 +79,7 @@ ] } }, - "trailer_id": { + "trailerId": { "param": "trailerId", "label": "Trailer", "description": "If this item has a trailer, select it here", @@ -87,7 +87,7 @@ "field_type": "media_select" } }, - "product_ids": { + "productIds": { "param": "productIds", "label": "Product IDs", "description": "Enter a CSV list of subscription assets that allow access to this content", @@ -96,7 +96,7 @@ "placeholder": "CSV subscription IDs" } }, - "live_status": { + "liveStatus": { "param": "VCH.EventState", "label": "Status", "description": "Do Not Modify - This is the state of the live event (populated automatically)", @@ -105,7 +105,7 @@ "placeholder": "This value will be populated automatically" } }, - "live_start_time": { + "liveStartTime": { "param": "VCH.ScheduledStart", "label": "Start Time", "description": "Do Not Modify - This is the schedules start time of the live event (populated automatically)", @@ -118,66 +118,20 @@ "sections": { "general": { "title": "General", - "fields": [ - "genre", - "rating", - "trailer_id" - ] + "fields": ["genre", "rating", "trailerId"] }, "access": { "title": "Access", "fields": [ { "param": "free", - "label": "Free to Watch?", + "label": "Free", "description": "If this item can be watched for free and doesn't require a login or subscription, you can set this value to true. Otherwise, if you leave this setting false, the application level subscription and authentication level rules will apply.", "details": { "field_type": "toggle" } }, - "product_ids" - ] - }, - "live_custom_params": { - "title": "Live Params", - "fields": [ - { - "param": "VCH.ID", - "label": "VCH ID", - "description": "Do Not Modify - This is the ID of the live event (populated automatically)", - "details": { - "field_type": "input", - "placeholder": "This value will be populated automatically" - } - }, - { - "param": "VCH.M3U8", - "label": "VCH M3U8", - "description": "Do Not Modify - Live event data (populated automatically)", - "details": { - "field_type": "input", - "placeholder": "This value will be populated automatically" - } - }, - { - "param": "VCH.MPD", - "label": "VCH MPD", - "description": "Do Not Modify - Live event data (populated automatically)", - "details": { - "field_type": "input", - "placeholder": "This value will be populated automatically" - } - }, - { - "param": "VCH.SmoothStream", - "label": "VCH SmoothStream", - "description": "Do Not Modify - Live event data (populated automatically)", - - "details": { - "field_type": "input", - "placeholder": "This value will be populated automatically" - } - } + "productIds" ] } }, @@ -189,10 +143,7 @@ "hosting_type": "hosted", "is_active": true, "is_series": false, - "sections": [ - "general", - "access" - ] + "sections": ["general", "access"] }, { "name": "series", @@ -201,9 +152,7 @@ "hosting_type": "ott_data", "is_active": true, "is_series": true, - "sections": [ - "general" - ] + "sections": ["general"] }, { "name": "episode", @@ -212,30 +161,52 @@ "hosting_type": "hosted", "is_active": true, "is_series": false, - "sections": [ - "general", - "access" - ] + "sections": [] }, { - "name": "channel", + "name": "liveChannel", "description": "Live Channel Schema", "display_name": "Live Channel", - "hosting_type": "external", + "hosting_type": "live_bcl", "is_active": true, "is_series": false, "sections": [ { "title": "Status", + "fields": ["liveStatus", "liveStartTime"] + }, + { + "title": "General", "fields": [ - "live_status", - "live_start_time" + { + "param": "liveChannelsId", + "label": "Playlist", + "description": "Playlist ID for the dynamic playlist containing your live channels ", + "required": true, + "details": { + "field_type": "playlist_multiselect" + } + } ] }, "access", { - "title": "Schedule", + "title": "Schedule (EPG)", "fields": [ + { + "param": "scheduleType", + "label": "Schedule Type", + "description": "EPG schedule type", + "details": { + "field_type": "select", + "placeholder": "Select a schedule type", + "default": "jwp", + "options": [ + { "value": "jwp", "label": "Default" }, + { "value": "viewnexa", "label": "ViewNexa" } + ] + } + }, { "param": "scheduleUrl", "label": "EPG Schedule URL", @@ -246,30 +217,29 @@ }, { "param": "scheduleDemo", - "label": "Use EPG Demo", + "label": "Demo Mode", "description": "Only enable this for non-production (demo) sites where you want the EPG schedule to loop", "details": { "field_type": "toggle" } } ] - }, - "live_custom_params" + } ] }, { - "name": "event", + "name": "liveEvent", "description": "Live Event Schema", "display_name": "Live Event", - "hosting_type": "external", + "hosting_type": "live_bcl", "is_active": true, "is_series": false, "sections": [ { "title": "Status", "fields": [ - "live_status", - "live_start_time", + "liveStatus", + "liveStartTime", { "param": "VCH.ScheduledEnd", "label": "End Time", @@ -281,8 +251,7 @@ } ] }, - "access", - "live_custom_params" + "access" ] }, { @@ -292,22 +261,7 @@ "hosting_type": "hosted", "is_active": true, "is_series": false, - "sections": [ - { - "title": "Access", - "fields": [ - { - "param": "free", - "label": "Free to Watch?", - "description": "Trailers can usually be watched for free, even if you have subscription based apps. If that is the case, set this value to true. Otherwise, set this value to false if you want to restrict viewing this trailer to only authenticated / paying customers.", - "details": { - "field_type": "toggle", - "default": true - } - } - ] - } - ] + "sections": [] }, { "name": "hub", @@ -328,24 +282,6 @@ "details": { "field_type": "playlist_multiselect" } - }, - { - "param": "logo", - "label": "Logo", - "description": "Enter the absolute path to a logo to display for this page", - "details": { - "field_type": "input", - "placeholder": "Enter the url of a logo" - } - }, - { - "param": "backgroundImage", - "label": "Background Image", - "description": "Enter the absolute path for a background image to display for this page", - "details": { - "field_type": "input", - "placeholder": "Enter the url of an image for the background" - } } ] } diff --git a/src/config.ts b/src/config.ts index 0259d9a98..a664f3984 100644 --- a/src/config.ts +++ b/src/config.ts @@ -42,7 +42,7 @@ export const CONTENT_TYPE = { // Page with a list of channels live: 'live', // Separate channel page - livechannel: 'livechannel', + liveChannel: 'livechannel', // Static page with markdown page: 'page', // Page with shelves list diff --git a/src/pages/ScreenRouting/MediaScreenRouter.tsx b/src/pages/ScreenRouting/MediaScreenRouter.tsx index cc8c6c4a3..b99942204 100644 --- a/src/pages/ScreenRouting/MediaScreenRouter.tsx +++ b/src/pages/ScreenRouting/MediaScreenRouter.tsx @@ -21,7 +21,7 @@ export const mediaScreenMap = new ScreenMap(); // Register media screens mediaScreenMap.registerByContentType(MediaSeries, CONTENT_TYPE.series); mediaScreenMap.registerByContentType(MediaEpisode, CONTENT_TYPE.episode); -mediaScreenMap.registerByContentType(MediaLiveChannel, CONTENT_TYPE.livechannel); +mediaScreenMap.registerByContentType(MediaLiveChannel, CONTENT_TYPE.liveChannel); mediaScreenMap.registerByContentType(MediaStaticPage, CONTENT_TYPE.page); mediaScreenMap.registerDefault(MediaMovie); From fc2668d587c041312deaae9fd47cf7a6f6f34681 Mon Sep 17 00:00:00 2001 From: Conventional Changelog Action Date: Thu, 18 Jan 2024 14:13:39 +0000 Subject: [PATCH 17/18] chore(release): v5.1.0 --- CHANGELOG.md | 25 +++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72d3deda7..a226dd35c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +## [5.1.0](https://github.com/jwplayer/ott-web-app/compare/v5.0.0...v5.1.0) (2024-01-18) + + +### Features + +* add user_id and profile_id for CDN analytics ([114150e](https://github.com/jwplayer/ott-web-app/commit/114150e6cdd8aaf436c06df30ea785f458b641f0)) +* add user_id and profile_id for CDN analytics ([0bacf37](https://github.com/jwplayer/ott-web-app/commit/0bacf37998904278b46bca17d6029f054150fc51)) +* cdn analytics code cleanup ([aeef40f](https://github.com/jwplayer/ott-web-app/commit/aeef40f6f375783cd0820fd27154b616570d3cf0)) +* cdn analytics code cleanup ([d92937d](https://github.com/jwplayer/ott-web-app/commit/d92937da7592c580df8230a4c1ee2fad3765ff55)) +* **epg:** fix live channel casing ([43c487c](https://github.com/jwplayer/ott-web-app/commit/43c487ca72bfdbf5977e6f873f3505fe5bf6011a)) +* **epg:** use getNamedModule ([0394daf](https://github.com/jwplayer/ott-web-app/commit/0394daf6ce8d73103aee5f5667a98fa089325569)) +* **project:** add view nexa epg provider ([9a71457](https://github.com/jwplayer/ott-web-app/commit/9a71457ae51dec38538e5b5ac719426250d3cae4)) +* **project:** change content type default schemas ([e100384](https://github.com/jwplayer/ott-web-app/commit/e1003844da97ba97618ed46e9eba076e49a6159c)) +* **project:** change the way of DI ([4154488](https://github.com/jwplayer/ott-web-app/commit/4154488dbe0e157ea92434382cc5d2d646cdd2bb)) +* **project:** review comments ([3359612](https://github.com/jwplayer/ott-web-app/commit/3359612cd029858a2ea3f596d23cfc835bda2fc8)) + + +### Bug Fixes + +* **epg:** check lower case, log error ([be774d4](https://github.com/jwplayer/ott-web-app/commit/be774d47780a238800796caf22700c3e4ce7c28f)) +* fix e2e test ([ba1e0de](https://github.com/jwplayer/ott-web-app/commit/ba1e0de5b60c8a5bfed1e52bacaf15d977b69894)) +* missing getState on useConfigStore under updateCardDetails ([#433](https://github.com/jwplayer/ott-web-app/issues/433)) ([ce32908](https://github.com/jwplayer/ott-web-app/commit/ce329084bf8565c475ea37e04006aaa08766c255)) +* place consents in appropriate section ([6281640](https://github.com/jwplayer/ott-web-app/commit/6281640fc4fb0f6f7b3b56cc34749465348cc7a2)) +* transform url to lowerCase ([68d31c4](https://github.com/jwplayer/ott-web-app/commit/68d31c46df3b5999f8bee79ed269e597b30993bf)) + ## [5.0.0](https://github.com/jwplayer/ott-web-app/compare/v4.31.1...v5.0.0) (2024-01-11) diff --git a/package.json b/package.json index 9ed6dfdd2..73deb7e56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jw-ott-webapp", - "version": "5.0.0", + "version": "5.1.0", "main": "index.js", "repository": "https://github.com/jwplayer/ott-web-app.git", "author": "JW Player", From 066971ab03a074d65e74746642a90ecce108b042 Mon Sep 17 00:00:00 2001 From: Anton Lantukh Date: Thu, 18 Jan 2024 16:55:36 +0100 Subject: [PATCH 18/18] fix(epg): fix repeatable keys for demo + empty service --- src/stores/EpgController.ts | 11 ++++++----- test-e2e/tests/live_channel_test.ts | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/stores/EpgController.ts b/src/stores/EpgController.ts index bc1783437..8bb30f219 100644 --- a/src/stores/EpgController.ts +++ b/src/stores/EpgController.ts @@ -34,8 +34,9 @@ export default class EpgController { const utcStartDate = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())); const daysDelta = differenceInDays(today, utcStartDate); - return programs.map((program) => ({ + return programs.map((program, idx) => ({ ...program, + id: `${program.id}_${idx}`, startTime: addDays(new Date(program.startTime), daysDelta).toJSON(), endTime: addDays(new Date(program.endTime), daysDelta).toJSON(), })); @@ -54,7 +55,7 @@ export default class EpgController { const transformResults = await Promise.allSettled( data.map((program) => epgService - .transformProgram(program) + ?.transformProgram(program) // This quiets promise resolution errors in the console .catch((error) => { logDev(error); @@ -78,7 +79,7 @@ export default class EpgController { getSchedule = async (item: PlaylistItem) => { const epgService = this.getEpgService(item); - const schedule = await epgService.fetchSchedule(item); + const schedule = await epgService?.fetchSchedule(item); const programs = await this.parseSchedule(schedule, item); const catchupHours = item.catchupHours && parseInt(item.catchupHours); @@ -95,8 +96,8 @@ export default class EpgController { }; getEpgService = (item: PlaylistItem) => { - const scheduleType = item?.scheduleType || EPG_TYPE.jwp; - const service = getNamedModule(EpgService, scheduleType?.toLowerCase()); + const scheduleType = item?.scheduleType?.toLocaleLowerCase() || EPG_TYPE.jwp; + const service = getNamedModule(EpgService, scheduleType, false); if (!service) { console.error(`No epg service was added for the ${scheduleType} schedule type`); diff --git a/test-e2e/tests/live_channel_test.ts b/test-e2e/tests/live_channel_test.ts index 855c40562..3b6e56d78 100644 --- a/test-e2e/tests/live_channel_test.ts +++ b/test-e2e/tests/live_channel_test.ts @@ -41,8 +41,8 @@ const shelfContainerLocator = locate({ css: 'div[role="row"]' }); const shelfLocator = locate({ css: 'div[role="cell"]' }).inside(shelfContainerLocator); const epgContainerLocator = locate({ css: 'div[data-testid="container"]' }); -const makeEpgProgramLocator = (id: string) => locate({ css: `div[data-testid="${id}"]` }).inside(epgContainerLocator); -const makeEpgChannelLocator = (id: string) => locate({ css: `div[data-testid="${id}"]` }).inside(epgContainerLocator); +const makeEpgProgramLocator = (id: string) => locate({ css: `div[data-testid*="${id}"]` }).inside(epgContainerLocator); +const makeEpgChannelLocator = (id: string) => locate({ css: `div[data-testid*="${id}"]` }).inside(epgContainerLocator); const channel1Locator = makeEpgChannelLocator(channel1Id); const channel2Locator = makeEpgChannelLocator(channel2Id);