Proyecto App Linux
This commit is contained in:
@@ -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.
|
||||
@@ -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: {},
|
||||
},
|
||||
];
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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() ||
|
||||
''
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
),
|
||||
];
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
)
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user