From 9a71457ae51dec38538e5b5ac719426250d3cae4 Mon Sep 17 00:00:00 2001 From: Anton Lantukh Date: Mon, 20 Nov 2023 11:59:31 +0100 Subject: [PATCH] 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"