From 4005687f1854c709c50e7ad4995b400a1892a011 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 10 Apr 2026 07:53:48 +0200 Subject: [PATCH] refactor: improve archive playback support and enhance EPG item display --- libs/m3u-state/src/index.ts | 1 + libs/m3u-state/src/lib/actions.ts | 2 + libs/m3u-state/src/lib/effects.spec.ts | 45 ++++ libs/m3u-state/src/lib/effects.ts | 104 ++++--- .../src/lib/external-player-payload.util.ts | 26 ++ .../src/lib/reducers/channel.reducers.ts | 2 + .../src/lib/reducers/epg.reducers.spec.ts | 42 +++ .../src/lib/reducers/epg.reducers.ts | 57 +--- libs/m3u-state/src/lib/reducers/index.ts | 2 + libs/m3u-state/src/lib/selectors.ts | 5 + libs/m3u-state/src/lib/state.ts | 2 + .../features/with-stalker-epg.feature.ts | 18 +- .../interfaces/src/lib/channel.model.ts | 2 + .../src/lib/parsed-playlist.interface.ts | 7 + libs/shared/m3u-utils/src/index.ts | 1 + .../m3u-utils/src/lib/catchup.utils.spec.ts | 254 ++++++++++++++++++ .../shared/m3u-utils/src/lib/catchup.utils.ts | 155 +++++++++++ .../epg-list-item.component.html | 18 +- .../epg-list-item.component.scss | 43 ++- .../epg-list-item.component.spec.ts | 34 ++- .../epg-list-item/epg-list-item.component.ts | 8 +- .../src/lib/epg-list/epg-list.component.html | 21 +- .../lib/epg-list/epg-list.component.spec.ts | 28 +- .../src/lib/epg-list/epg-list.component.ts | 30 ++- 24 files changed, 775 insertions(+), 132 deletions(-) create mode 100644 libs/m3u-state/src/lib/effects.spec.ts create mode 100644 libs/m3u-state/src/lib/external-player-payload.util.ts create mode 100644 libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts create mode 100644 libs/shared/m3u-utils/src/lib/catchup.utils.ts diff --git a/libs/m3u-state/src/index.ts b/libs/m3u-state/src/index.ts index 7bc70e1e5..d096c533a 100644 --- a/libs/m3u-state/src/index.ts +++ b/libs/m3u-state/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/actions'; export * from './lib/effects'; +export * from './lib/external-player-payload.util'; export * from './lib/reducers'; export * from './lib/router.selectors'; export * from './lib/selectors'; diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index 7d86d663f..05db351b0 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -47,8 +47,10 @@ export const EpgActions = createActionGroup({ source: 'EPG', events: { 'Set Active Epg Program': props<{ program: EpgProgram }>(), + 'Set Active Playback Url': props<{ playbackUrl: string }>(), 'Set Current Epg Program': props<{ program: EpgProgram }>(), 'Reset Active Epg Program': emptyProps(), + 'Return To Live Playback': emptyProps(), 'Set Epg Available Flag': props<{ value: boolean }>(), }, }); diff --git a/libs/m3u-state/src/lib/effects.spec.ts b/libs/m3u-state/src/lib/effects.spec.ts new file mode 100644 index 000000000..60abd11b6 --- /dev/null +++ b/libs/m3u-state/src/lib/effects.spec.ts @@ -0,0 +1,45 @@ +import { Channel } from 'shared-interfaces'; +import { buildExternalPlayerPayload } from './external-player-payload.util'; + +describe('buildExternalPlayerPayload', () => { + const activeChannel: Channel = { + id: 'channel-1', + name: 'Sample TV', + url: 'https://streams.example.com/live.m3u8', + group: { title: 'News' }, + tvg: { + id: 'sample-tv', + name: 'Sample TV', + url: '', + logo: '', + rec: '3', + }, + catchup: { + type: 'shift', + days: '3', + }, + timeshift: '3', + http: { + referrer: 'https://referrer.example.com', + 'user-agent': 'Codex Test Agent', + origin: 'https://origin.example.com', + }, + radio: 'false', + epgParams: '', + }; + + it('uses the resolved archive url while preserving headers and title', () => { + expect( + buildExternalPlayerPayload( + activeChannel, + 'https://archive.example.com/replay.m3u8?utc=123&lutc=456' + ) + ).toEqual({ + url: 'https://archive.example.com/replay.m3u8?utc=123&lutc=456', + title: 'Sample TV', + 'user-agent': 'Codex Test Agent', + referer: 'https://referrer.example.com', + origin: 'https://origin.example.com', + }); + }); +}); diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index f31af09f1..074dab8d4 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { EpgService } from '@iptvnator/epg/data-access'; +import { resolveM3uCatchupUrl } from 'm3u-utils'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; @@ -19,6 +20,7 @@ import { DataService, PlaylistsService } from 'services'; import { OPEN_MPV_PLAYER, OPEN_VLC_PLAYER, + Channel, Playlist, STORE_KEY, VideoPlayer, @@ -35,6 +37,7 @@ import { selectChannels, selectFavorites, } from './selectors'; +import { buildExternalPlayerPayload } from './external-player-payload.util'; @Injectable({ providedIn: 'any' }) export class PlaylistEffects { @@ -91,45 +94,48 @@ export class PlaylistEffects { } ); - setActiveEpgProgram$ = createEffect( + resolveActiveEpgProgram$ = createEffect(() => { + return this.actions$.pipe( + ofType(EpgActions.setActiveEpgProgram), + withLatestFrom(this.store.select(selectActive)), + map(([action, activeChannel]) => { + const playbackUrl = activeChannel + ? resolveM3uCatchupUrl(activeChannel, action.program) + : null; + + return playbackUrl + ? EpgActions.setActivePlaybackUrl({ playbackUrl }) + : EpgActions.resetActiveEpgProgram(); + }) + ); + }); + + openArchivedPlayback$ = createEffect( () => { return this.actions$.pipe( - ofType(EpgActions.setActiveEpgProgram), + ofType(EpgActions.setActivePlaybackUrl), withLatestFrom(this.store.select(selectActive)), - map(([, activeChannel]) => { - firstValueFrom(this.storage.get(STORE_KEY.Settings)).then( - (settings: any) => { - if ( - settings && - Object.keys(settings).length > 0 && - settings.player === VideoPlayer.MPV - ) - this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { - url: - activeChannel?.url + - (activeChannel?.epgParams ?? ''), - title: activeChannel?.name ?? '', - 'user-agent': - activeChannel?.http?.['user-agent'], - referer: activeChannel?.http?.referrer, - origin: activeChannel?.http?.origin, - }); - else if ( - settings && - Object.keys(settings).length > 0 && - settings.player === VideoPlayer.VLC - ) - this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { - url: - activeChannel?.url + - (activeChannel?.epgParams ?? ''), - title: activeChannel?.name ?? '', - 'user-agent': - activeChannel?.http?.['user-agent'], - referer: activeChannel?.http?.referrer, - origin: activeChannel?.http?.origin, - }); - } + tap(([action, activeChannel]) => { + void this.openWithConfiguredExternalPlayer( + action.playbackUrl, + activeChannel + ); + }) + ); + }, + { dispatch: false } + ); + + returnToLivePlayback$ = createEffect( + () => { + return this.actions$.pipe( + ofType(EpgActions.returnToLivePlayback), + withLatestFrom(this.store.select(selectActive)), + filter(([, activeChannel]) => Boolean(activeChannel?.url)), + tap(([, activeChannel]) => { + void this.openWithConfiguredExternalPlayer( + activeChannel?.url ?? '', + activeChannel ); }) ); @@ -212,6 +218,32 @@ export class PlaylistEffects { ); }); + private async openWithConfiguredExternalPlayer( + playbackUrl: string, + activeChannel: Channel | undefined | null + ): Promise { + const payload = buildExternalPlayerPayload(activeChannel, playbackUrl); + if (!payload) { + return; + } + + const settings: any = await firstValueFrom( + this.storage.get(STORE_KEY.Settings) + ); + if (!settings || Object.keys(settings).length === 0) { + return; + } + + if (settings.player === VideoPlayer.MPV) { + this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, payload); + return; + } + + if (settings.player === VideoPlayer.VLC) { + this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, payload); + } + } + removePlaylist$ = createEffect( () => { return this.actions$.pipe( diff --git a/libs/m3u-state/src/lib/external-player-payload.util.ts b/libs/m3u-state/src/lib/external-player-payload.util.ts new file mode 100644 index 000000000..902a15235 --- /dev/null +++ b/libs/m3u-state/src/lib/external-player-payload.util.ts @@ -0,0 +1,26 @@ +import { Channel } from 'shared-interfaces'; + +export function buildExternalPlayerPayload( + activeChannel: Channel | undefined | null, + playbackUrl: string +): + | { + url: string; + title: string; + 'user-agent': string | undefined; + referer: string | undefined; + origin: string | undefined; + } + | null { + if (!playbackUrl || !activeChannel) { + return null; + } + + return { + url: playbackUrl, + title: activeChannel.name ?? '', + 'user-agent': activeChannel.http?.['user-agent'], + referer: activeChannel.http?.referrer, + origin: activeChannel.http?.origin, + }; +} diff --git a/libs/m3u-state/src/lib/reducers/channel.reducers.ts b/libs/m3u-state/src/lib/reducers/channel.reducers.ts index c4d1de1be..f86664a3f 100644 --- a/libs/m3u-state/src/lib/reducers/channel.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/channel.reducers.ts @@ -11,6 +11,7 @@ export const channelReducers = [ return { ...state, active: { ...channel, epgParams: '' } as Channel, + activePlaybackUrl: null, }; } ), @@ -18,6 +19,7 @@ export const channelReducers = [ return { ...state, active: undefined, + activePlaybackUrl: null, }; }), on(ChannelActions.setChannels, (state, action): PlaylistState => { diff --git a/libs/m3u-state/src/lib/reducers/epg.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/epg.reducers.spec.ts index 8916bfc69..d7164f018 100644 --- a/libs/m3u-state/src/lib/reducers/epg.reducers.spec.ts +++ b/libs/m3u-state/src/lib/reducers/epg.reducers.spec.ts @@ -29,6 +29,7 @@ describe('epgReducers', () => { radio: 'false', epgParams: '', }, + activePlaybackUrl: null, }; const nextState = reducer(state, EpgActions.resetActiveEpgProgram()); @@ -59,11 +60,52 @@ describe('epgReducers', () => { radio: 'false', epgParams: '?utc=123&lutc=456', }, + activePlaybackUrl: 'https://archive.example.com/catchup.m3u8', }; const nextState = reducer(state, EpgActions.resetActiveEpgProgram()); expect(nextState).not.toBe(state); expect(nextState.active?.epgParams).toBe(''); + expect(nextState.activePlaybackUrl).toBeNull(); + }); + + it('stores the resolved playback url without mutating the active channel', () => { + const state = { + ...initialState, + active: { + id: 'channel-1', + name: 'ARD-alpha', + url: 'https://example.com/live.m3u8', + tvg: { + id: '', + name: 'ARD-alpha', + url: '', + logo: '', + rec: '', + }, + group: { title: '' }, + http: { + referrer: '', + 'user-agent': '', + origin: '', + }, + radio: 'false', + epgParams: '', + }, + activePlaybackUrl: null, + }; + + const nextState = reducer( + state, + EpgActions.setActivePlaybackUrl({ + playbackUrl: 'https://archive.example.com/catchup.m3u8', + }) + ); + + expect(nextState.activePlaybackUrl).toBe( + 'https://archive.example.com/catchup.m3u8' + ); + expect(nextState.active?.url).toBe('https://example.com/live.m3u8'); }); }); diff --git a/libs/m3u-state/src/lib/reducers/epg.reducers.ts b/libs/m3u-state/src/lib/reducers/epg.reducers.ts index 6ff313be4..aafce29f4 100644 --- a/libs/m3u-state/src/lib/reducers/epg.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/epg.reducers.ts @@ -3,64 +3,29 @@ import { Channel } from 'shared-interfaces'; import { EpgActions } from '../actions'; import { PlaylistState } from '../state'; -function toUnixTimestamp(value: string): number { - const parsed = Date.parse(value); - if (Number.isFinite(parsed)) { - return Math.floor(parsed / 1000); - } - - const match = value.match( - /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{2})(\d{2})$/ - ); - if (!match) { - return Math.floor(Date.now() / 1000); - } - - const [, year, month, day, hour, minute, offsetHours, offsetMinutes] = - match; - const utcMillis = Date.UTC( - Number(year), - Number(month) - 1, - Number(day), - Number(hour), - Number(minute) - ); - const offsetTotalMinutes = - Number(offsetHours) * 60 + - Math.sign(Number(offsetHours)) * Number(offsetMinutes); - - return Math.floor( - (utcMillis - offsetTotalMinutes * 60_000) / 1000 - ); -} - export const epgReducers = [ - on(EpgActions.setActiveEpgProgram, (state, action): PlaylistState => { - const { program } = action; - const from = toUnixTimestamp(program.start); - const now = Math.floor(Date.now() / 1000); - const epgParams = `?utc=${from}&lutc=${now}`; - return { - ...state, - active: state.active - ? ({ ...state.active, epgParams } as Channel) - : undefined, - }; - }), + on(EpgActions.setActivePlaybackUrl, (state, action): PlaylistState => ({ + ...state, + activePlaybackUrl: action.playbackUrl, + })), on( EpgActions.resetActiveEpgProgram, + EpgActions.returnToLivePlayback, (state): PlaylistState => { - if (!state.active) { + if (!state.active && !state.activePlaybackUrl) { return state; } - if (!state.active.epgParams) { + if (!state.activePlaybackUrl && !state.active?.epgParams) { return state; } return { ...state, - active: { ...state.active, epgParams: '' } as Channel, + activePlaybackUrl: null, + active: state.active + ? ({ ...state.active, epgParams: '' } as Channel) + : undefined, }; } ), diff --git a/libs/m3u-state/src/lib/reducers/index.ts b/libs/m3u-state/src/lib/reducers/index.ts index 1eb959974..0a473c2e4 100644 --- a/libs/m3u-state/src/lib/reducers/index.ts +++ b/libs/m3u-state/src/lib/reducers/index.ts @@ -23,6 +23,8 @@ export const selectPlaylistId = (state: PlaylistState) => export const selectIsEpgAvailableReducer = (state: PlaylistState) => state.epgAvailable; export const selectActiveReducer = (state: PlaylistState) => state.active; +export const selectActivePlaybackUrlReducer = (state: PlaylistState) => + state.activePlaybackUrl; export const selectCurrentEpgProgramReducer = (state: PlaylistState) => state.currentEpgProgram; export const selectChannelsReducer = (state: PlaylistState) => state.channels; diff --git a/libs/m3u-state/src/lib/selectors.ts b/libs/m3u-state/src/lib/selectors.ts index b63a481a0..6010f35cf 100644 --- a/libs/m3u-state/src/lib/selectors.ts +++ b/libs/m3u-state/src/lib/selectors.ts @@ -17,6 +17,11 @@ export const selectActive = createSelector( fromPlaylistState.selectActiveReducer ); +export const selectActivePlaybackUrl = createSelector( + selectPlaylistState, + fromPlaylistState.selectActivePlaybackUrlReducer +); + export const selectCurrentEpgProgram = createSelector( selectPlaylistState, fromPlaylistState.selectCurrentEpgProgramReducer diff --git a/libs/m3u-state/src/lib/state.ts b/libs/m3u-state/src/lib/state.ts index 4093adc95..7ac73f58d 100644 --- a/libs/m3u-state/src/lib/state.ts +++ b/libs/m3u-state/src/lib/state.ts @@ -5,6 +5,7 @@ import { EpgProgram } from 'shared-interfaces'; export interface PlaylistState { active: Channel | undefined; + activePlaybackUrl: string | null; currentEpgProgram: EpgProgram | undefined; epgAvailable: boolean; channels: Channel[]; // TODO: use entity store @@ -13,6 +14,7 @@ export interface PlaylistState { export const initialState: PlaylistState = { active: undefined, + activePlaybackUrl: null, currentEpgProgram: undefined, epgAvailable: false, channels: [], diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts index 055f671ad..0764128cb 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts @@ -1,13 +1,13 @@ import { inject } from '@angular/core'; import { signalStoreFeature, withMethods } from '@ngrx/signals'; import { DataService } from 'services'; -import { StalkerSessionService } from '../../stalker-session.service'; import { EpgItem, Playlist, STALKER_REQUEST, StalkerPortalActions, } from 'shared-interfaces'; +import { StalkerSessionService } from '../../stalker-session.service'; interface EpgStoreContext { currentPlaylist(): Playlist | undefined; @@ -66,18 +66,18 @@ export function withStalkerEpg() { } else { response = await dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: playlist.portalUrl, - macAddress: playlist.macAddress, - params: queryParams, - } - ); + STALKER_REQUEST, + { + url: playlist.portalUrl, + macAddress: playlist.macAddress, + params: queryParams, + } + ); } const epgData = Array.isArray(response?.js) ? response.js - : response?.js?.data ?? []; + : (response?.js?.data ?? []); const items = Array.isArray(epgData) ? epgData : []; return items.map((item) => ({ diff --git a/libs/shared/interfaces/src/lib/channel.model.ts b/libs/shared/interfaces/src/lib/channel.model.ts index 9ef7f065f..df61322b8 100755 --- a/libs/shared/interfaces/src/lib/channel.model.ts +++ b/libs/shared/interfaces/src/lib/channel.model.ts @@ -12,7 +12,9 @@ export function createChannel(params: Partial): Channel { url: params.url, tvg: params.tvg, timeshift: params.timeshift, + catchup: params.catchup, http: params.http, + radio: params.radio, } as Channel; } diff --git a/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts b/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts index 55da99961..79c623e4a 100644 --- a/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts @@ -26,4 +26,11 @@ export interface ParsedPlaylistItem { }; url: string; raw: string; + catchup?: { + type?: string; + source?: string; + days?: string; + }; + timeshift?: string; + radio?: string; } diff --git a/libs/shared/m3u-utils/src/index.ts b/libs/shared/m3u-utils/src/index.ts index 152e37a23..27a97c636 100644 --- a/libs/shared/m3u-utils/src/index.ts +++ b/libs/shared/m3u-utils/src/index.ts @@ -1 +1,2 @@ export * from './lib/playlist.utils'; +export * from './lib/catchup.utils'; diff --git a/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts b/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts new file mode 100644 index 000000000..b0c054577 --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts @@ -0,0 +1,254 @@ +import { Channel, EpgProgram } from 'shared-interfaces'; +import { + getM3uArchiveDays, + isM3uCatchupPlaybackSupported, + resolveM3uCatchupUrl, +} from './catchup.utils'; +import { createPlaylistObject } from './playlist.utils'; + +describe('catchup.utils', () => { + const baseChannel: Channel = { + id: 'channel-1', + name: 'News One', + url: 'https://streams.example.com/live/channel-1.m3u8', + group: { title: 'News' }, + tvg: { + id: 'news-1', + name: 'News One', + url: '', + logo: '', + rec: '3', + }, + catchup: { + type: 'shift', + days: '3', + }, + timeshift: '3', + http: { + referrer: '', + 'user-agent': '', + origin: '', + }, + radio: 'false', + epgParams: '', + }; + + const archivedProgram: EpgProgram = { + start: '2026-04-10T08:00:00.000Z', + stop: '2026-04-10T09:00:00.000Z', + channel: 'news-1', + title: 'Morning News', + desc: null, + category: null, + startTimestamp: 1_775_808_800, + stopTimestamp: 1_775_812_400, + }; + + it('reports archive days from catchup metadata before legacy fields', () => { + expect( + getM3uArchiveDays({ + ...baseChannel, + catchup: { type: 'shift', days: '5' }, + timeshift: '3', + tvg: { ...baseChannel.tvg, rec: '2' }, + }) + ).toBe(5); + }); + + it('falls back to tvg-rec when catchup-days and timeshift are blank strings', () => { + expect( + getM3uArchiveDays({ + ...baseChannel, + catchup: { type: '', days: '' }, + timeshift: '', + tvg: { ...baseChannel.tvg, rec: '7' }, + }) + ).toBe(7); + }); + + it('supports legacy same-stream shift playback when catchup type is shift', () => { + expect(isM3uCatchupPlaybackSupported(baseChannel)).toBe(true); + expect(resolveM3uCatchupUrl(baseChannel, archivedProgram, 1_775_820_000)) + .toBe( + 'https://streams.example.com/live/channel-1.m3u8?utc=1775808800&lutc=1775820000' + ); + }); + + it('supports tvg-rec-only legacy same-stream playback when the channel url is reusable', () => { + const channel: Channel = { + ...baseChannel, + tvg: { + ...baseChannel.tvg, + rec: '7', + }, + catchup: undefined, + timeshift: undefined, + }; + + expect(isM3uCatchupPlaybackSupported(channel)).toBe(true); + expect(resolveM3uCatchupUrl(channel, archivedProgram, 1_775_820_000)) + .toBe( + 'https://streams.example.com/live/channel-1.m3u8?utc=1775808800&lutc=1775820000' + ); + }); + + it('rewrites utc and lutc query params on catchup-source urls', () => { + const playbackUrl = resolveM3uCatchupUrl( + { + ...baseChannel, + catchup: { + type: 'append', + days: '3', + source: + 'https://archive.example.com/catchup.m3u8?utc=1&lutc=2&token=abc', + }, + }, + archivedProgram, + 1_775_820_000 + ); + + expect(playbackUrl).toBe( + 'https://archive.example.com/catchup.m3u8?utc=1775808800&lutc=1775820000&token=abc' + ); + }); + + it('returns null when the channel metadata declares an unsupported replay scheme', () => { + expect( + isM3uCatchupPlaybackSupported({ + ...baseChannel, + catchup: { + type: 'append', + days: '3', + source: '', + }, + timeshift: '3', + }) + ).toBe(false); + expect( + resolveM3uCatchupUrl( + { + ...baseChannel, + catchup: { + type: 'append', + days: '3', + source: '', + }, + timeshift: '3', + }, + archivedProgram + ) + ).toBeNull(); + }); + + it('returns null for tvg-rec-only channels when the stream url is not http', () => { + expect( + isM3uCatchupPlaybackSupported({ + ...baseChannel, + url: 'udp://239.0.0.1:1234', + tvg: { + ...baseChannel.tvg, + rec: '7', + }, + catchup: undefined, + timeshift: undefined, + }) + ).toBe(false); + expect( + resolveM3uCatchupUrl( + { + ...baseChannel, + url: 'udp://239.0.0.1:1234', + tvg: { + ...baseChannel.tvg, + rec: '7', + }, + catchup: undefined, + timeshift: undefined, + }, + archivedProgram + ) + ).toBeNull(); + }); + + it('returns null when archive days are missing', () => { + expect( + resolveM3uCatchupUrl( + { + ...baseChannel, + tvg: { ...baseChannel.tvg, rec: '' }, + timeshift: '', + catchup: { + type: 'shift', + days: '', + }, + }, + archivedProgram + ) + ).toBeNull(); + }); + + it('falls back to parsing the program start string when the unix timestamp is unavailable', () => { + expect( + resolveM3uCatchupUrl( + baseChannel, + { + ...archivedProgram, + start: '202604100800 +0000', + startTimestamp: null, + }, + 1_775_820_000 + ) + ).toBe( + 'https://streams.example.com/live/channel-1.m3u8?utc=1775808000&lutc=1775820000' + ); + }); + + it('preserves catchup metadata when storing parsed playlist items', () => { + const playlist = createPlaylistObject('Catchup playlist', { + header: { + attrs: { + 'x-tvg-url': 'https://example.com/guide.xml', + }, + raw: '#EXTM3U x-tvg-url="https://example.com/guide.xml"', + }, + items: [ + { + name: 'News One', + tvg: { + id: 'news-1', + name: 'News One', + url: '', + logo: '', + rec: '3', + }, + group: { title: 'News' }, + http: { + referrer: '', + 'user-agent': '', + }, + url: 'https://streams.example.com/live/channel-1.m3u8', + raw: '#EXTINF:-1,News One', + catchup: { + type: 'shift', + source: 'https://archive.example.com/catchup.m3u8', + days: '3', + }, + timeshift: '3', + radio: 'false', + }, + ], + }); + + expect(playlist.playlist?.items[0]).toEqual( + expect.objectContaining({ + catchup: { + type: 'shift', + source: 'https://archive.example.com/catchup.m3u8', + days: '3', + }, + timeshift: '3', + radio: 'false', + }) + ); + }); +}); diff --git a/libs/shared/m3u-utils/src/lib/catchup.utils.ts b/libs/shared/m3u-utils/src/lib/catchup.utils.ts new file mode 100644 index 000000000..fff3fab1c --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/catchup.utils.ts @@ -0,0 +1,155 @@ +import { Channel, EpgProgram } from 'shared-interfaces'; + +const XMLTV_TIMESTAMP_PATTERN = + /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{2})(\d{2})$/; + +type CatchupSupportMode = 'none' | 'source' | 'shift'; + +export function getM3uArchiveDays( + channel: Pick | null | undefined +): number { + const value = getFirstNonBlankValue( + channel?.catchup?.days, + channel?.timeshift, + channel?.tvg?.rec + ); + return Math.max(0, Number(value ?? 0) || 0); +} + +export function isM3uCatchupPlaybackSupported( + channel: Channel | null | undefined +): boolean { + return getM3uCatchupSupportMode(channel) !== 'none'; +} + +export function resolveM3uCatchupUrl( + channel: Channel | null | undefined, + program: Pick, + nowTimestampSeconds = Math.floor(Date.now() / 1000) +): string | null { + const supportMode = getM3uCatchupSupportMode(channel); + if (supportMode === 'none') { + return null; + } + + const startTimestamp = getEpgProgramTimestampSeconds( + program.start, + program.startTimestamp + ); + if (startTimestamp === null) { + return null; + } + + const playbackBaseUrl = + supportMode === 'source' + ? channel?.catchup?.source + : channel?.url; + if (!playbackBaseUrl?.trim()) { + return null; + } + + const normalizedNow = + Number.isFinite(nowTimestampSeconds) && nowTimestampSeconds > 0 + ? Math.floor(nowTimestampSeconds) + : Math.floor(Date.now() / 1000); + + return setCatchupQueryParams(playbackBaseUrl, { + utc: startTimestamp, + lutc: normalizedNow, + }); +} + +function getM3uCatchupSupportMode( + channel: Channel | null | undefined +): CatchupSupportMode { + if (getM3uArchiveDays(channel) <= 0) { + return 'none'; + } + + const catchupSource = channel?.catchup?.source?.trim() ?? ''; + if (isHttpUrl(catchupSource)) { + return 'source'; + } + + const streamUrl = channel?.url?.trim() ?? ''; + const catchupType = channel?.catchup?.type?.trim().toLowerCase() ?? ''; + if (catchupType === 'shift' && isHttpUrl(streamUrl)) { + return 'shift'; + } + + if (!catchupType && isHttpUrl(streamUrl)) { + return 'shift'; + } + + return 'none'; +} + +function getEpgProgramTimestampSeconds( + dateValue: string, + unixTimestampValue?: number | string | null +): number | null { + const unixTimestamp = Number.parseInt(String(unixTimestampValue ?? ''), 10); + if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) { + return unixTimestamp; + } + + const parsed = Date.parse(dateValue); + if (Number.isFinite(parsed)) { + return Math.floor(parsed / 1000); + } + + const match = dateValue.match(XMLTV_TIMESTAMP_PATTERN); + if (!match) { + return null; + } + + const [, year, month, day, hour, minute, offsetHours, offsetMinutes] = + match; + const utcMillis = Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute) + ); + const offsetTotalMinutes = + Number(offsetHours) * 60 + + Math.sign(Number(offsetHours)) * Number(offsetMinutes); + + return Math.floor((utcMillis - offsetTotalMinutes * 60_000) / 1000); +} + +function setCatchupQueryParams( + rawUrl: string, + params: Record<'utc' | 'lutc', number> +): string | null { + try { + const url = new URL(rawUrl.trim()); + url.searchParams.set('utc', String(params.utc)); + url.searchParams.set('lutc', String(params.lutc)); + return url.toString(); + } catch { + return null; + } +} + +function isHttpUrl(value: string): boolean { + if (!value) { + return false; + } + + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +function getFirstNonBlankValue( + ...values: Array +): string | undefined { + return values.find( + (value): value is string => value != null && value.trim() !== '' + ); +} diff --git a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html index 2a5af5028..a155705ca 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html +++ b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html @@ -3,18 +3,24 @@ - {{ item.stop | momentDate: 'HH:mm' }} -@if (timeNow >= item.start && timeNow <= item.stop) { +@if (isLive) { live } -@if ( - item.start < timeNow && item.stop < timeNow && item.start > timeshiftUntil -) { - r + + {{ 'XTREAM.PLAY' | translate }} + } @if (item?.desc) { diff --git a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.scss b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.scss index 8df7f827b..32015d2c5 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.scss +++ b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.scss @@ -20,27 +20,58 @@ display: inline-flex; align-items: center; justify-content: center; + gap: 6px; min-width: 42px; padding: 2px 8px; border-radius: 999px; font-size: 0.68rem; line-height: 1.35; font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.02em; border: 1px solid transparent; + white-space: nowrap; } .live { color: #ff8f8f; background: rgba(255, 95, 95, 0.12); border-color: rgba(255, 95, 95, 0.28); + text-transform: uppercase; + letter-spacing: 0.08em; } -.rec { - color: #9abfff; - background: color-mix(in srgb, var(--app-selection-color) 14%, transparent); - border-color: color-mix(in srgb, var(--app-selection-border) 72%, transparent); +.archive-playback { + color: color-mix(in srgb, var(--app-selection-color) 92%, white); + background: + linear-gradient( + 135deg, + color-mix(in srgb, var(--app-selection-color) 16%, transparent), + color-mix(in srgb, var(--app-selection-color) 9%, transparent) + ), + color-mix(in srgb, var(--app-selection-surface) 42%, transparent); + border-color: color-mix( + in srgb, + var(--app-selection-border) 78%, + transparent + ); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 6px 14px -12px var(--app-selection-glow); + padding-inline: 7px 10px; +} + +.archive-playback__icon { + width: 14px; + height: 14px; + font-size: 14px; + color: inherit; + opacity: 0.92; +} + +.archive-playback__label { + font-size: 0.69rem; + line-height: 1; + font-weight: 700; } .description-icon { diff --git a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts index 17355cba5..3b6503ae9 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts @@ -4,7 +4,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatListModule } from '@angular/material/list'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MomentDatePipe } from '@iptvnator/pipes'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import moment from 'moment'; import { MockModule, MockPipe } from 'ng-mocks'; import { EpgProgram } from 'shared-interfaces'; @@ -59,6 +59,7 @@ describe('EpgListItemComponent', () => { let component: EpgListItemComponent; let fixture: ComponentFixture; let dialog: MatDialog; + let translate: TranslateService; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -76,6 +77,20 @@ describe('EpgListItemComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(EpgListItemComponent); dialog = TestBed.inject(MatDialog); + translate = TestBed.inject(TranslateService); + translate.setTranslation( + 'en', + { + EPG: { + TIMESHIFT_AVAILABLE: 'Archive playback is available', + }, + XTREAM: { + PLAY: 'Play', + }, + }, + true + ); + translate.use('en'); component = fixture.componentInstance; component.item = EPG_PROGRAM_ITEM; fixture.detectChanges(); @@ -94,4 +109,21 @@ describe('EpgListItemComponent', () => { width: '800px', }); }); + + it('renders an archive playback chip with replay icon and label', () => { + fixture = TestBed.createComponent(EpgListItemComponent); + component = fixture.componentInstance; + component.item = EPG_PROGRAM_ITEM; + component.showArchiveBadge = true; + fixture.detectChanges(); + + const chip = fixture.nativeElement.querySelector('.archive-playback'); + const icon = fixture.nativeElement.querySelector( + '.archive-playback__icon' + ); + + expect(chip).not.toBeNull(); + expect(chip.textContent).toContain('Play'); + expect(icon.textContent.trim()).toBe('replay'); + }); }); diff --git a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.ts b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.ts index 705f4e35b..1ec3099d5 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.ts @@ -19,11 +19,11 @@ export class EpgListItemComponent { /** EPG Program to render */ @Input() item!: EpgProgram; - /** Actual time */ - @Input() timeNow!: string; + /** Whether the program is currently live */ + @Input() isLive = false; - /** Aviability of the timeshift function until date */ - @Input() timeshiftUntil!: string; + /** Whether archive playback is available for the program */ + @Input() showArchiveBadge = false; /** * Opens the dialog with details about the selected program diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.html b/libs/ui/epg/src/lib/epg-list/epg-list.component.html index 73ffb9caa..3900d6b14 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.html +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.html @@ -33,18 +33,18 @@
@if (items.length > 0) { @for (program of items; track $index) { - @if ( - program.start < timeshiftLimit || program.start >= timeNow() - ) { + @if (!canActivateProgram(program)) {
@@ -70,17 +70,16 @@
(); - readonly timeNow = input(''); - readonly timeshiftUntil = input(''); + readonly isLive = input(false); + readonly showArchiveBadge = input(false); } describe('EpgListComponent', () => { @@ -214,6 +214,30 @@ describe('EpgListComponent', () => { expect(scrollToSpy).toHaveBeenCalledTimes(1); }); + + it('shows archived rows as clickable only when archive playback is supported', () => { + const programs = buildPrograms(); + fixture.componentRef.setInput('controlledPrograms', programs); + fixture.componentRef.setInput('archivePlaybackAvailable', false); + + fixture.detectChanges(); + + expect(component.canActivateProgram(programs[0])).toBe(false); + expect(component.canActivateProgram(programs[1])).toBe(true); + expect( + fixture.nativeElement.querySelectorAll('.program-item.clickable') + .length + ).toBe(1); + + fixture.componentRef.setInput('archivePlaybackAvailable', true); + fixture.detectChanges(); + + expect(component.canActivateProgram(programs[0])).toBe(true); + expect( + fixture.nativeElement.querySelectorAll('.program-item.clickable') + .length + ).toBe(2); + }); }); function buildPrograms(overrides?: { diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts index c036baa86..4b28ad387 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts @@ -48,6 +48,7 @@ export class EpgListComponent { readonly controlledChannel = input(null); readonly controlledPrograms = input(null); readonly controlledArchiveDays = input(null); + readonly archivePlaybackAvailable = input(null); readonly programActivated = output(); private readonly store = inject(Store); @@ -64,7 +65,7 @@ export class EpgListComponent { readonly programList = viewChild>('programList'); readonly selectedDate = signal(moment().format(DATE_FORMAT)); readonly timeNow = signal(new Date().toISOString()); - readonly todayDate = signal(moment().format(DATE_FORMAT)); + readonly currentTimeMs = computed(() => Date.parse(this.timeNow())); readonly isControlled = computed( () => @@ -106,6 +107,9 @@ export class EpgListComponent { .subtract(this.archiveDays(), 'days') .toISOString() ); + readonly archivePlaybackEnabled = computed( + () => this.archivePlaybackAvailable() ?? (this.archiveDays() > 0) + ); readonly filteredItems = computed(() => [...this.items()] .filter( @@ -136,9 +140,7 @@ export class EpgListComponent { effect(() => { const programs = this.items(); const channel = this.displayChannel(); - - this.timeNow.set(new Date().toISOString()); - this.todayDate.set(moment().format(DATE_FORMAT)); + this.currentTimeMs(); if (!this.isControlled()) { this.store.dispatch( @@ -182,7 +184,7 @@ export class EpgListComponent { activateProgram(program: EpgProgram): void { const isLive = this.isProgramPlaying(program); - const isTimeshift = this.isProgramArchived(program); + const isTimeshift = this.canPlayArchivedProgram(program); if (!isLive && !isTimeshift) { return; @@ -198,7 +200,7 @@ export class EpgListComponent { } if (isLive) { - this.store.dispatch(EpgActions.resetActiveEpgProgram()); + this.store.dispatch(EpgActions.returnToLivePlayback()); } else { this.store.dispatch(EpgActions.setActiveEpgProgram({ program })); } @@ -207,11 +209,17 @@ export class EpgListComponent { } canActivateProgram(program: EpgProgram): boolean { - return this.isProgramPlaying(program) || this.isProgramArchived(program); + return ( + this.isProgramPlaying(program) || this.canPlayArchivedProgram(program) + ); + } + + canPlayArchivedProgram(program: EpgProgram): boolean { + return this.archivePlaybackEnabled() && this.isProgramArchived(program); } calculateProgress(program: EpgProgram): number { - const now = Date.now(); + const now = this.currentTimeMs(); const start = getProgramTimeMs(program.start, program.startTimestamp); const stop = getProgramTimeMs(program.stop, program.stopTimestamp); const total = stop - start; @@ -221,7 +229,7 @@ export class EpgListComponent { } isProgramPlaying(program: EpgProgram): boolean { - const now = Date.now(); + const now = this.currentTimeMs(); const start = getProgramTimeMs(program.start, program.startTimestamp); const stop = getProgramTimeMs(program.stop, program.stopTimestamp); return now >= start && now <= stop; @@ -232,7 +240,7 @@ export class EpgListComponent { return false; } - const now = Date.now(); + const now = this.currentTimeMs(); const start = getProgramTimeMs(program.start, program.startTimestamp); const stop = getProgramTimeMs(program.stop, program.stopTimestamp); const archiveLimit = Date.parse(this.timeshiftUntil()); @@ -241,7 +249,7 @@ export class EpgListComponent { } private findCurrentProgram(programs: EpgProgram[]): EpgProgram | undefined { - const now = Date.now(); + const now = this.currentTimeMs(); return programs.find((program) => { const start = getProgramTimeMs( program.start,