Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# m3u-state
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test m3u-state` to execute the unit tests.
+34
View File
@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
+21
View File
@@ -0,0 +1,21 @@
export default {
displayName: 'm3u-state',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../coverage/libs/m3u-state',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
+21
View File
@@ -0,0 +1,21 @@
{
"name": "m3u-state",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/m3u-state/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:data-access"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/m3u-state/jest.config.ts",
"tsConfig": "libs/m3u-state/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+7
View File
@@ -0,0 +1,7 @@
export * from './lib/actions';
export * from './lib/channel-epg-lookup.util';
export * from './lib/effects';
export * from './lib/external-player-payload.util';
export * from './lib/reducers';
export * from './lib/router.selectors';
export * from './lib/selectors';
+72
View File
@@ -0,0 +1,72 @@
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Channel, EpgProgram, Playlist, PlaylistMeta } from 'shared-interfaces';
export const PlaylistActions = createActionGroup({
source: 'Playlists',
events: {
'Load Playlists': emptyProps(),
'Load Playlists Success': props<{ playlists: PlaylistMeta[] }>(),
'Add Playlist': props<{ playlist: Playlist }>(),
'Add Many Playlists': props<{ playlists: Playlist[] }>(),
'Remove Playlist': props<{ playlistId: string }>(),
'Update Playlist Meta': props<{ playlist: PlaylistMeta }>(),
'Update Playlist': props<{ playlist: Playlist; playlistId: string }>(),
'Update Many Playlists': props<{ playlists: Playlist[] }>(),
'Parse Playlist': props<{
uploadType: 'FILE' | 'URL' | 'TEXT';
playlist: string;
title: string;
path?: string;
}>(),
'Set Active Playlist': props<{ playlistId: string }>(),
'Update Playlist Positions': props<{
positionUpdates: { id: string; changes: { position: number } }[];
}>(),
'Remove All Playlists': emptyProps(),
'Handle Adding Playlist By Url': props<{
isTemporary: boolean;
playlist: Playlist;
}>(),
},
});
export const ChannelActions = createActionGroup({
source: 'Channels',
events: {
'Set Channels Loading': props<{ loading: boolean }>(),
'Set Channels': props<{ channels: Channel[] }>(),
'Set Active Channel': props<{ channel: Channel }>(),
'Set Active Channel Success': props<{ channel: Channel }>(),
'Reset Active Channel': emptyProps(),
'Set Adjacent Channel As Active': props<{
direction: 'next' | 'previous';
}>(),
},
});
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 }>(),
},
});
export const FavoritesActions = createActionGroup({
source: 'Favorites',
events: {
'Update Favorites': props<{ channel: Channel }>(),
'Set Favorites': props<{ channelIds: string[] }>(),
},
});
export const FilterActions = createActionGroup({
source: 'Filters',
events: {
'Set Selected Filters': props<{ selectedFilters: string[] }>(),
},
});
@@ -0,0 +1,65 @@
import { Channel } from 'shared-interfaces';
import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util';
function buildChannel(overrides: Partial<Channel> = {}): Channel {
return {
id: 'channel-1',
url: 'https://example.com/live.m3u8',
name: 'Fallback Name',
group: { title: 'News' },
tvg: {
id: 'epg-id',
name: 'Guide Name',
url: '',
logo: '',
rec: '',
},
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
...overrides,
};
}
describe('resolveChannelEpgLookupKey', () => {
it('prefers tvg id when present', () => {
expect(resolveChannelEpgLookupKey(buildChannel())).toBe('epg-id');
});
it('falls back to tvg name before channel name', () => {
expect(
resolveChannelEpgLookupKey(
buildChannel({
tvg: {
id: ' ',
name: 'Guide Name',
url: '',
logo: '',
rec: '',
},
name: 'Channel Name',
})
)
).toBe('Guide Name');
});
it('falls back to channel name when tvg fields are empty', () => {
expect(
resolveChannelEpgLookupKey(
buildChannel({
tvg: {
id: ' ',
name: ' ',
url: '',
logo: '',
rec: '',
},
name: 'Channel Name',
})
)
).toBe('Channel Name');
});
});
@@ -0,0 +1,12 @@
import { Channel } from 'shared-interfaces';
export function resolveChannelEpgLookupKey(
channel: Channel | null | undefined
): string {
return (
channel?.tvg?.id?.trim() ||
channel?.tvg?.name?.trim() ||
channel?.name?.trim() ||
''
);
}
+45
View File
@@ -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',
});
});
});
+412
View File
@@ -0,0 +1,412 @@
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';
import { TranslateService } from '@ngx-translate/core';
import {
EMPTY,
filter,
firstValueFrom,
map,
switchMap,
tap,
withLatestFrom,
} from 'rxjs';
import { DataService, PlaylistsService } from 'services';
import {
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
Channel,
Playlist,
STORE_KEY,
VideoPlayer,
} from 'shared-interfaces';
import {
ChannelActions,
EpgActions,
FavoritesActions,
PlaylistActions,
} from './actions';
import {
selectActive,
selectActivePlaylistId,
selectChannels,
selectFavorites,
} from './selectors';
import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util';
import { buildExternalPlayerPayload } from './external-player-payload.util';
@Injectable({ providedIn: 'any' })
export class PlaylistEffects {
private actions$ = inject(Actions);
private playlistsService = inject(PlaylistsService);
private dataService = inject(DataService);
private epgService = inject(EpgService);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
private storage = inject(StorageMap);
private store = inject(Store);
private translate = inject(TranslateService);
updateFavorites$ = createEffect(
() => {
return this.actions$.pipe(
ofType(FavoritesActions.updateFavorites),
withLatestFrom(
this.store.select(selectFavorites),
this.store.select(selectActivePlaylistId)
),
filter(([, , playlistId]) => !!playlistId),
switchMap(([, favorites, playlistId]) =>
this.playlistsService.updateFavorites(playlistId, favorites)
),
tap(() => {
this.snackBar.open(
this.translate.instant('CHANNELS.FAVORITES_UPDATED'),
undefined,
{ duration: 2000 }
);
})
);
},
{ dispatch: false }
);
setFavorites$ = createEffect(
() => {
return this.actions$.pipe(
ofType(FavoritesActions.setFavorites),
withLatestFrom(this.store.select(selectActivePlaylistId)),
filter(([, playlistId]) => !!playlistId),
switchMap(([action, playlistId]) =>
this.playlistsService.setFavorites(
playlistId,
action.channelIds
)
)
);
},
{
dispatch: false,
}
);
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.setActivePlaybackUrl),
withLatestFrom(this.store.select(selectActive)),
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
);
})
);
},
{ dispatch: false }
);
setActiveChannel$ = createEffect(() => {
return this.actions$.pipe(
ofType(ChannelActions.setActiveChannel),
// Skip the effect entirely when channel is falsy
filter((action) => !!action.channel),
map((action) => {
const { channel } = action;
// Use modern EPG service to get channel programs
const channelId = resolveChannelEpgLookupKey(channel);
if (channelId) {
this.epgService.getChannelPrograms(channelId);
}
// Set user agent if specified on channel
if (channel.http['user-agent']) {
window.electron?.setUserAgent(
channel.http['user-agent'],
channel.http.referrer
);
}
firstValueFrom(this.storage.get(STORE_KEY.Settings)).then(
(settings: any) => {
if (
settings &&
Object.keys(settings).length > 0 &&
settings.player === VideoPlayer.MPV &&
channel.radio !== 'true'
) {
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url: channel.url,
title: channel.name ?? '',
'user-agent': channel.http['user-agent'],
referer: channel.http.referrer,
origin: channel.http.origin,
});
} else if (
settings &&
Object.keys(settings).length > 0 &&
settings.player === VideoPlayer.VLC &&
channel.radio !== 'true'
)
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: channel.url,
title: channel.name ?? '',
'user-agent': channel.http['user-agent'],
referer: channel.http.referrer,
origin: channel.http.origin,
});
}
);
return ChannelActions.setActiveChannelSuccess({
channel: action.channel,
});
})
);
});
loadPlaylists$ = createEffect(() => {
return this.actions$.pipe(
ofType(PlaylistActions.loadPlaylists),
switchMap(() =>
this.playlistsService.getAllPlaylists().pipe(
map((playlists) =>
PlaylistActions.loadPlaylistsSuccess({
playlists,
})
)
)
)
);
});
private async openWithConfiguredExternalPlayer(
playbackUrl: string,
activeChannel: Channel | undefined | null
): Promise<void> {
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(
ofType(PlaylistActions.removePlaylist),
switchMap(async (action) => {
await firstValueFrom(
this.playlistsService.deletePlaylist(action.playlistId)
);
})
);
},
{ dispatch: false }
);
updatePlaylist$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylist),
switchMap((action) =>
this.playlistsService.updatePlaylist(action.playlistId, {
...action.playlist,
_id: action.playlistId,
})
)
);
},
{ dispatch: false }
);
parsePlaylist$ = createEffect(() => {
return this.actions$.pipe(
ofType(PlaylistActions.parsePlaylist),
map((action) =>
this.playlistsService.handlePlaylistParsing(
action.uploadType,
action.playlist,
action.title,
action.path
)
),
map((playlist) =>
PlaylistActions.addPlaylist({
playlist,
})
)
);
});
addPlaylist$ = createEffect(
() => {
return this.actions$.pipe(
ofType(
PlaylistActions.addPlaylist,
PlaylistActions.handleAddingPlaylistByUrl
),
tap((action) => {
if ('isTemporary' in action && action.isTemporary) {
return;
}
this.navigateToPlaylist(action.playlist);
}),
switchMap((action) => {
if ('isTemporary' in action && action.isTemporary) {
return EMPTY;
}
return this.playlistsService.addPlaylist(action.playlist);
})
);
},
{ dispatch: false }
);
updatePlaylistMeta$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylistMeta),
switchMap((action) =>
this.playlistsService.updatePlaylistMeta(action.playlist)
)
);
},
{ dispatch: false }
);
updatePlaylistPositions$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylistPositions),
switchMap((action) =>
this.playlistsService.updatePlaylistPositions(
action.positionUpdates
)
)
);
},
{ dispatch: false }
);
addManyPlaylists$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.addManyPlaylists),
switchMap((action) =>
this.playlistsService.addManyPlaylists(action.playlists)
)
);
},
{ dispatch: false }
);
updateManyPlaylists$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updateManyPlaylists),
switchMap((action) =>
this.playlistsService.updateManyPlaylists(action.playlists)
)
);
},
{ dispatch: false }
);
setAdjacentChannelAsActive$ = createEffect(() => {
return this.actions$.pipe(
ofType(ChannelActions.setAdjacentChannelAsActive),
withLatestFrom(
this.store.select(selectChannels),
this.store.select(selectActive)
),
map(([action, channels, activeChannel]) => {
let adjacentChannel;
const index = channels.findIndex(
(channel) => channel.id === activeChannel?.id
);
if (action.direction === 'next') {
if (index === channels.length - 1)
adjacentChannel = activeChannel;
adjacentChannel = channels[index + 1];
} else if (action.direction === 'previous') {
if (index === -1 || index === 0)
adjacentChannel = activeChannel;
adjacentChannel = channels[index - 1];
}
return ChannelActions.setActiveChannelSuccess({
channel: adjacentChannel!,
});
})
);
});
private navigateToPlaylist(playlist: Playlist): void {
if (playlist.serverUrl) {
void this.router.navigate(['/workspace', 'xtreams', playlist._id]);
return;
}
if (playlist.macAddress) {
void this.router.navigate(['/workspace', 'stalker', playlist._id]);
return;
}
void this.router.navigate(['/workspace', 'playlists', playlist._id]);
}
}
@@ -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,
};
}
+27
View File
@@ -0,0 +1,27 @@
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { PlaylistMeta } from 'shared-interfaces';
export const playlistsAdapter: EntityAdapter<PlaylistMeta> =
createEntityAdapter<PlaylistMeta>({
selectId: (item: PlaylistMeta) => item._id,
});
export interface PlaylistMetaState extends EntityState<PlaylistMeta> {
selectedId: string;
allPlaylistsLoaded: boolean;
selectedFilters: string[];
}
export const initialPlaylistMetaState: PlaylistMetaState =
playlistsAdapter.getInitialState({
selectedId: '',
allPlaylistsLoaded: false,
selectedFilters: ['m3u', 'xtream', 'stalker'],
});
export const {
selectIds,
selectEntities: getPlaylistMetaEntities,
selectAll: getAllPlaylistsMeta,
selectTotal,
} = playlistsAdapter.getSelectors();
+2
View File
@@ -0,0 +1,2 @@
// Re-export from the new modular reducers structure
export * from './reducers/index';
@@ -0,0 +1,53 @@
import { createReducer } from '@ngrx/store';
import { Channel } from 'shared-interfaces';
import { ChannelActions } from '../actions';
import { initialState } from '../state';
import { channelReducers } from './channel.reducers';
const reducer = createReducer(initialState, ...channelReducers);
describe('channelReducers', () => {
const sampleChannel = {
epgParams: '',
http: {
origin: '',
referrer: '',
'user-agent': '',
},
id: 'channel-1',
name: 'Sample TV',
radio: 'false',
tvg: {
id: 'sample-tvg-id',
logo: '',
name: 'Sample TV',
rec: '',
url: '',
},
url: 'https://example.com/live.m3u8',
} as Channel;
it('tracks explicit channel loading state', () => {
const nextState = reducer(
initialState,
ChannelActions.setChannelsLoading({ loading: true })
);
expect(nextState.channelsLoading).toBe(true);
});
it('stores channels and clears the loading flag when channel data arrives', () => {
const loadingState = reducer(
initialState,
ChannelActions.setChannelsLoading({ loading: true })
);
const nextState = reducer(
loadingState,
ChannelActions.setChannels({ channels: [sampleChannel] })
);
expect(nextState.channels).toEqual([sampleChannel]);
expect(nextState.channelsLoading).toBe(false);
});
});
@@ -0,0 +1,38 @@
import { on } from '@ngrx/store';
import { Channel } from 'shared-interfaces';
import { ChannelActions } from '../actions';
import { PlaylistState } from '../state';
export const channelReducers = [
on(ChannelActions.setChannelsLoading, (state, action): PlaylistState => {
return {
...state,
channelsLoading: action.loading,
};
}),
on(
ChannelActions.setActiveChannelSuccess,
(state, action): PlaylistState => {
const { channel } = action;
return {
...state,
active: { ...channel, epgParams: '' } as Channel,
activePlaybackUrl: null,
};
}
),
on(ChannelActions.resetActiveChannel, (state): PlaylistState => {
return {
...state,
active: undefined,
activePlaybackUrl: null,
};
}),
on(ChannelActions.setChannels, (state, action): PlaylistState => {
return {
...state,
channelsLoading: false,
channels: action.channels,
};
}),
];
@@ -0,0 +1,111 @@
import { createReducer } from '@ngrx/store';
import { EpgActions } from '../actions';
import { initialState } from '../state';
import { epgReducers } from './epg.reducers';
const reducer = createReducer(initialState, ...epgReducers);
describe('epgReducers', () => {
it('does not recreate the active channel when epg params are already empty', () => {
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.resetActiveEpgProgram());
expect(nextState).toBe(state);
});
it('clears epg params when they are present', () => {
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: '?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');
});
});
@@ -0,0 +1,46 @@
import { on } from '@ngrx/store';
import { Channel } from 'shared-interfaces';
import { EpgActions } from '../actions';
import { PlaylistState } from '../state';
export const epgReducers = [
on(EpgActions.setActivePlaybackUrl, (state, action): PlaylistState => ({
...state,
activePlaybackUrl: action.playbackUrl,
})),
on(
EpgActions.resetActiveEpgProgram,
EpgActions.returnToLivePlayback,
(state): PlaylistState => {
if (!state.active && !state.activePlaybackUrl) {
return state;
}
if (!state.activePlaybackUrl && !state.active?.epgParams) {
return state;
}
return {
...state,
activePlaybackUrl: null,
active: state.active
? ({ ...state.active, epgParams: '' } as Channel)
: undefined,
};
}
),
on(
EpgActions.setCurrentEpgProgram,
(state, action): PlaylistState => ({
...state,
currentEpgProgram: action.program,
})
),
on(
EpgActions.setEpgAvailableFlag,
(state, action): PlaylistState => ({
...state,
epgAvailable: action.value,
})
),
];
@@ -0,0 +1,57 @@
import { on } from '@ngrx/store';
import { FavoritesActions } from '../actions';
import { PlaylistState } from '../state';
export const favoritesReducers = [
on(FavoritesActions.updateFavorites, (state, action): PlaylistState => {
const selectedId = state.playlists.selectedId;
const playlist = state.playlists.entities[selectedId];
if (!selectedId || !playlist) {
return state;
}
const { channel } = action;
const playlistFavorites = (playlist.favorites ?? []).filter(
(favorite): favorite is string => typeof favorite === 'string'
);
const favorites = playlistFavorites.includes(channel.url)
? playlistFavorites.filter((url) => url !== channel.url)
: [...playlistFavorites, channel.url];
return {
...state,
playlists: {
...state.playlists,
entities: {
...state.playlists.entities,
[selectedId]: {
...playlist,
favorites,
},
},
},
};
}),
on(FavoritesActions.setFavorites, (state, action): PlaylistState => {
const selectedId = state.playlists.selectedId;
const playlist = state.playlists.entities[selectedId];
if (!selectedId || !playlist) {
return state;
}
const { channelIds } = action;
return {
...state,
playlists: {
...state.playlists,
entities: {
...state.playlists.entities,
[selectedId]: {
...playlist,
favorites: channelIds,
},
},
},
};
}),
];
@@ -0,0 +1,18 @@
import { on } from '@ngrx/store';
import { FilterActions } from '../actions';
import { PlaylistState } from '../state';
export const filterReducers = [
on(
FilterActions.setSelectedFilters,
(state, { selectedFilters }): PlaylistState => {
return {
...state,
playlists: {
...state.playlists,
selectedFilters,
},
};
}
),
];
+34
View File
@@ -0,0 +1,34 @@
import { createReducer } from '@ngrx/store';
import { initialState, PlaylistState } from '../state';
import { channelReducers } from './channel.reducers';
import { epgReducers } from './epg.reducers';
import { favoritesReducers } from './favorites.reducers';
import { filterReducers } from './filter.reducers';
import { playlistReducers } from './playlist.reducers';
export const playlistReducer = createReducer(
initialState,
...epgReducers,
...channelReducers,
...playlistReducers,
...favoritesReducers,
...filterReducers
);
// Selectors
export const selectPlaylists = (state: PlaylistState) => state.playlists;
export const selectPlaylistId = (state: PlaylistState) =>
state.playlists?.selectedId;
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 selectChannelsLoadingReducer = (state: PlaylistState) =>
state.channelsLoading;
export const selectChannelsReducer = (state: PlaylistState) => state.channels;
export const selectPlaylistsLoadingFlagReducer = (state: PlaylistState) =>
state.playlists?.allPlaylistsLoaded;
@@ -0,0 +1,133 @@
import { createReducer } from '@ngrx/store';
import { PlaylistActions } from '../actions';
import { playlistsAdapter } from '../playlists.state';
import { initialState } from '../state';
import { playlistReducers } from './playlist.reducers';
import { Channel, Playlist, PlaylistMeta } from 'shared-interfaces';
const reducer = createReducer(initialState, ...playlistReducers);
describe('playlistReducers', () => {
it('persists updateDate and hiddenGroupTitles when playlist meta is updated', () => {
const existingPlaylist: PlaylistMeta = {
_id: 'playlist-1',
title: 'Xtream Playlist',
count: 0,
importDate: '2026-03-28T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
};
const state = {
...initialState,
playlists: playlistsAdapter.addOne(
existingPlaylist,
initialState.playlists
),
};
const nextState = reducer(
state,
PlaylistActions.updatePlaylistMeta({
playlist: {
...existingPlaylist,
hiddenGroupTitles: ['Movies', 'News'],
updateDate: 1712145600000,
},
})
);
expect(nextState.playlists.entities['playlist-1']?.updateDate).toBe(
1712145600000
);
expect(
nextState.playlists.entities['playlist-1']?.hiddenGroupTitles
).toEqual(['Movies', 'News']);
});
it('updates the active playlist channel cache and clears loading on playlist refresh', () => {
const refreshedChannel = {
epgParams: '',
http: {
origin: '',
referrer: '',
'user-agent': '',
},
id: 'channel-1',
name: 'Refreshed TV',
radio: 'false',
tvg: {
id: 'channel-1',
logo: '',
name: 'Refreshed TV',
rec: '',
url: '',
},
url: 'https://example.com/refreshed.m3u8',
} as Channel;
const existingPlaylist: PlaylistMeta = {
_id: 'playlist-1',
count: 1,
importDate: '2026-03-28T00:00:00.000Z',
title: 'Playlist One',
} as PlaylistMeta;
const refreshedPlaylist = {
playlist: {
items: [refreshedChannel],
},
} as Playlist;
const state = {
...initialState,
channelsLoading: true,
playlists: playlistsAdapter.addOne(existingPlaylist, {
...initialState.playlists,
selectedId: 'playlist-1',
}),
};
const nextState = reducer(
state,
PlaylistActions.updatePlaylist({
playlist: refreshedPlaylist,
playlistId: 'playlist-1',
})
);
expect(nextState.channels).toEqual([refreshedChannel]);
expect(nextState.channelsLoading).toBe(false);
});
it('keeps hiddenGroupTitles on playlist refresh when the refreshed payload omits them', () => {
const existingPlaylist: PlaylistMeta = {
_id: 'playlist-1',
count: 1,
hiddenGroupTitles: ['Radio-de'],
importDate: '2026-03-28T00:00:00.000Z',
title: 'Playlist One',
} as PlaylistMeta;
const state = {
...initialState,
playlists: playlistsAdapter.addOne(existingPlaylist, {
...initialState.playlists,
selectedId: 'playlist-1',
}),
};
const nextState = reducer(
state,
PlaylistActions.updatePlaylist({
playlist: {
playlist: {
items: [],
},
} as Playlist,
playlistId: 'playlist-1',
})
);
expect(
nextState.playlists.entities['playlist-1']?.hiddenGroupTitles
).toEqual(['Radio-de']);
});
});
@@ -0,0 +1,197 @@
import { on } from '@ngrx/store';
import { Channel } from 'shared-interfaces';
import { PlaylistActions } from '../actions';
import { playlistsAdapter } from '../playlists.state';
import { PlaylistState } from '../state';
export const playlistReducers = [
on(PlaylistActions.loadPlaylistsSuccess, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addMany(action.playlists, {
...state.playlists,
allPlaylistsLoaded: true,
}),
};
}),
on(PlaylistActions.removePlaylist, (state, action): PlaylistState => {
const playlists = playlistsAdapter.removeOne(
action.playlistId,
state.playlists
);
return {
...state,
playlists: {
...playlists,
selectedId:
state.playlists.selectedId === action.playlistId
? ''
: playlists.selectedId,
},
};
}),
on(PlaylistActions.updatePlaylist, (state, action): PlaylistState => {
const isActivePlaylist =
state.playlists.selectedId === action.playlistId;
return {
...state,
channels: isActivePlaylist
? (action.playlist.playlist.items as Channel[])
: state.channels,
channelsLoading: isActivePlaylist ? false : state.channelsLoading,
playlists: playlistsAdapter.updateOne(
{
id: action.playlistId,
changes: {
...action.playlist,
_id: action.playlistId,
updateDate: Date.now(),
count: action.playlist.playlist.items.length,
userAgent: action.playlist.userAgent,
favorites:
state.playlists.entities[action.playlistId]
?.favorites ?? [],
},
},
state.playlists
),
};
}),
on(PlaylistActions.addPlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addOne(
action.playlist,
state.playlists
),
};
}),
on(PlaylistActions.addManyPlaylists, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addMany(
action.playlists,
state.playlists
),
};
}),
on(PlaylistActions.setActivePlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: {
...state.playlists,
selectedId: action.playlistId,
},
};
}),
on(
PlaylistActions.updatePlaylistPositions,
(state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateMany(
action.positionUpdates,
state.playlists
),
};
}
),
on(PlaylistActions.updatePlaylistMeta, (state, action): PlaylistState => {
const p = action.playlist;
return {
...state,
playlists: playlistsAdapter.updateOne(
{
id: p._id,
changes: {
...(p.title != null ? { title: p.title } : {}),
...(p.autoRefresh != null
? { autoRefresh: p.autoRefresh }
: {}),
...(p.userAgent != null
? { userAgent: p.userAgent }
: {}),
...(p.referrer !== undefined
? { referrer: p.referrer }
: {}),
...(p.origin !== undefined ? { origin: p.origin } : {}),
...(p.serverUrl != null
? { serverUrl: p.serverUrl }
: {}),
...(p.username != null ? { username: p.username } : {}),
...(p.password != null ? { password: p.password } : {}),
...(p.macAddress != null
? { macAddress: p.macAddress }
: {}),
...(p.portalUrl != null
? { portalUrl: p.portalUrl }
: {}),
...(p.isFullStalkerPortal !== undefined
? {
isFullStalkerPortal: p.isFullStalkerPortal,
}
: {}),
...(p.favorites != null
? { favorites: p.favorites }
: {}),
...(p.recentlyViewed != null
? { recentlyViewed: p.recentlyViewed }
: {}),
...(p.hiddenGroupTitles != null
? { hiddenGroupTitles: p.hiddenGroupTitles }
: {}),
...(p.updateDate !== undefined
? { updateDate: p.updateDate }
: {}),
},
},
state.playlists
),
};
}),
on(PlaylistActions.updateManyPlaylists, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateMany(
action.playlists.map((updatedPlaylist) => ({
id: updatedPlaylist._id,
changes: {
...updatedPlaylist,
updateDate: Date.now(),
},
})),
state.playlists
),
};
}),
on(PlaylistActions.removeAllPlaylists, (state): PlaylistState => {
const playlists = playlistsAdapter.removeAll(state.playlists);
return {
...state,
playlists: {
...playlists,
selectedId: '',
},
};
}),
on(
PlaylistActions.handleAddingPlaylistByUrl,
(state, action): PlaylistState => {
if (action.isTemporary) {
return {
...state,
channelsLoading: false,
channels: action.playlist.playlist.items as Channel[],
};
} else {
return {
...state,
playlists: playlistsAdapter.addOne(
action.playlist,
state.playlists
),
};
}
}
),
];
@@ -0,0 +1,14 @@
import { getRouterSelectors } from '@ngrx/router-store';
export const {
selectCurrentRoute,
selectFragment,
selectQueryParams,
selectQueryParam,
selectRouteParams,
selectRouteParam,
selectRouteData,
selectRouteDataParam,
selectUrl,
selectTitle,
} = getRouterSelectors();
+45
View File
@@ -0,0 +1,45 @@
import { PlaylistMeta } from 'shared-interfaces';
import { PlaylistState } from './state';
import { selectActivePlaylist, selectChannelsLoading } from './selectors';
describe('selectors', () => {
const playlistOne = {
_id: 'playlist-1',
title: 'Playlist One',
} as PlaylistMeta;
const playlistTwo = {
_id: 'playlist-2',
title: 'Playlist Two',
} as PlaylistMeta;
const entities = {
'playlist-1': playlistOne,
'playlist-2': playlistTwo,
};
describe('selectActivePlaylist', () => {
it('resolves the active playlist from entity state and selected id', () => {
expect(selectActivePlaylist.projector(entities, 'playlist-2')).toBe(
playlistTwo
);
});
it('returns null when there is no active playlist id', () => {
expect(selectActivePlaylist.projector(entities, '')).toBeNull();
});
});
describe('selectChannelsLoading', () => {
it('returns the current M3U channel loading flag', () => {
expect(
selectChannelsLoading.projector({
channelsLoading: true,
} as PlaylistState)
).toBe(true);
expect(
selectChannelsLoading.projector({
channelsLoading: false,
} as PlaylistState)
).toBe(false);
});
});
});
+105
View File
@@ -0,0 +1,105 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { Playlist } from 'shared-interfaces';
import * as fromPlaylistMetaState from './playlists.state';
import * as fromPlaylistState from './reducers';
import { PlaylistState } from './state';
export const selectPlaylistState =
createFeatureSelector<PlaylistState>('playlistState');
export const selectIsEpgAvailable = createSelector(
selectPlaylistState,
fromPlaylistState.selectIsEpgAvailableReducer
);
export const selectActive = createSelector(
selectPlaylistState,
fromPlaylistState.selectActiveReducer
);
export const selectActivePlaybackUrl = createSelector(
selectPlaylistState,
fromPlaylistState.selectActivePlaybackUrlReducer
);
export const selectCurrentEpgProgram = createSelector(
selectPlaylistState,
fromPlaylistState.selectCurrentEpgProgramReducer
);
export const selectChannelsLoading = createSelector(
selectPlaylistState,
fromPlaylistState.selectChannelsLoadingReducer
);
export const selectChannels = createSelector(
selectPlaylistState,
fromPlaylistState.selectChannelsReducer
);
export const selectPlaylistsLoadingFlag = createSelector(
selectPlaylistState,
fromPlaylistState.selectPlaylistsLoadingFlagReducer
);
/** Playlist entity store selectors */
export const selectPlaylistsMetaState = createSelector(
selectPlaylistState,
fromPlaylistState.selectPlaylists
);
export const selectAllPlaylistsMeta = createSelector(
selectPlaylistsMetaState,
fromPlaylistMetaState.getAllPlaylistsMeta
);
export const selectActiveTypeFilters = createSelector(
selectPlaylistsMetaState,
(state) => state.selectedFilters
);
export const selectPlaylistEntity = (id: string) =>
createSelector(selectPlaylistEntities, (entities) => entities[id]);
export const selectActivePlaylistId = createSelector(
selectPlaylistsMetaState,
(state) => state.selectedId
);
export const selectPlaylistEntities = createSelector(
selectPlaylistsMetaState,
fromPlaylistMetaState.getPlaylistMetaEntities
);
export const selectActivePlaylist = createSelector(
selectPlaylistEntities,
selectActivePlaylistId,
(entities, activePlaylistId) => {
if (!entities || !activePlaylistId) {
return null;
}
return entities[activePlaylistId] ?? null;
}
);
export const selectPlaylistTitle = createSelector(
selectActivePlaylist,
(playlist) => playlist?.title || playlist?.filename || 'Untitled playlist'
);
export const selectPlaylistById = (id: string) =>
createSelector(selectPlaylistEntities, (entities) => {
if (entities) {
return entities[id] as Playlist;
}
return null;
});
export const selectFavorites = createSelector(
selectActivePlaylist,
(playlist) =>
(playlist?.favorites || []).filter(
(favorite): favorite is string => typeof favorite === 'string'
)
);
+24
View File
@@ -0,0 +1,24 @@
import { Channel } from 'shared-interfaces';
import { initialPlaylistMetaState, PlaylistMetaState } from './playlists.state';
import { EpgProgram } from 'shared-interfaces';
export interface PlaylistState {
active: Channel | undefined;
activePlaybackUrl: string | null;
currentEpgProgram: EpgProgram | undefined;
epgAvailable: boolean;
channelsLoading: boolean;
channels: Channel[]; // TODO: use entity store
playlists: PlaylistMetaState;
}
export const initialState: PlaylistState = {
active: undefined,
activePlaybackUrl: null,
currentEpgProgram: undefined,
epgAvailable: false,
channelsLoading: false,
channels: [],
playlists: initialPlaylistMetaState,
};
+6
View File
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"files": ["../../global.d.ts"],
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}