refactor: update nx monorepo structure

This commit is contained in:
4gray committed 2026-03-09 21:44:28 +01:00
1 parent 0ad1eeb785
commit aa739f43e0
63 files changed
+1113 -427

No files matched your search

@@ -1,5 +1,3 @@
export * from './lib/global-favorites/global-favorites-page.component';
export * from './lib/workspace-context-panel/settings-context.service';
export * from './lib/workspace-shell/workspace-shell-actions';
export * from './lib/workspace-shell/workspace-shell.component';
export * from './lib/workspace-sources/workspace-sources.component';
@@ -32,6 +32,7 @@ import {
Channel,
EpgItem,
EpgProgram,
Playlist,
ResolvedPortalPlayback,
STALKER_REQUEST,
StalkerPortalActions,
@@ -41,6 +42,12 @@ import { GlobalFavoritesListComponent } from './global-favorites-list.component'
import { GlobalFavoritesService } from './global-favorites.service';
import { UnifiedFavoriteChannel } from './unified-favorite-channel.interface';
interface StalkerCreateLinkResponse {
readonly js?: {
readonly cmd?: string;
};
}
@Component({
selector: 'app-global-favorites-page',
templateUrl: './global-favorites-page.component.html',
@@ -248,7 +255,7 @@ export class GlobalFavoritesPageComponent implements OnInit {
epgMap: Map<string, EpgProgram | null>,
now: number
): Promise<void> {
let playlist: any;
let playlist: Playlist | undefined;
try {
playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
@@ -257,6 +264,10 @@ export class GlobalFavoritesPageComponent implements OnInit {
return;
}
if (!playlist?.serverUrl || !playlist.username || !playlist.password) {
return;
}
const credentials = {
serverUrl: playlist.serverUrl,
username: playlist.username,
@@ -355,9 +366,16 @@ export class GlobalFavoritesPageComponent implements OnInit {
}));
}
if (ch.sourceType === 'xtream' && ch.xtreamId) {
const playlist = await firstValueFrom(
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(ch.playlistId)
);
)) as Playlist | undefined;
if (
!playlist?.serverUrl ||
!playlist.username ||
!playlist.password
) {
return [];
}
return await this.xtreamApi.getShortEpg(
{
serverUrl: playlist.serverUrl,
@@ -387,18 +405,23 @@ export class GlobalFavoritesPageComponent implements OnInit {
private async resolveXtreamPlayback(
ch: UnifiedFavoriteChannel
): Promise<ResolvedPortalPlayback> {
const playlist = await firstValueFrom(
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(ch.playlistId)
);
)) as Playlist | undefined;
if (
ch.xtreamId == null ||
!playlist?.serverUrl ||
!playlist.username ||
!playlist.password
) {
throw new Error('Missing Xtream playback credentials');
}
const credentials = {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
};
const streamUrl = this.xtreamUrl.constructLiveUrl(
credentials,
ch.xtreamId!
);
const streamUrl = this.xtreamUrl.constructLiveUrl(credentials, ch.xtreamId);
return {
streamUrl,
title: ch.name,
@@ -409,13 +432,13 @@ export class GlobalFavoritesPageComponent implements OnInit {
private async resolveStalkerPlayback(
ch: UnifiedFavoriteChannel
): Promise<ResolvedPortalPlayback> {
const playlist = await firstValueFrom(
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(ch.playlistId)
);
)) as Playlist | undefined;
const portalUrl =
ch.stalkerPortalUrl ?? playlist.portalUrl ?? playlist.url;
const macAddress = ch.stalkerMacAddress ?? playlist.macAddress;
ch.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? '';
const macAddress = ch.stalkerMacAddress ?? playlist?.macAddress ?? '';
const cmd = ch.stalkerCmd ?? '';
const params = {
@@ -427,8 +450,8 @@ export class GlobalFavoritesPageComponent implements OnInit {
JsHttpRequest: '1-xml',
};
let response: any;
if (playlist.isFullStalkerPortal) {
let response: StalkerCreateLinkResponse | undefined;
if (playlist?.isFullStalkerPortal && playlist) {
response = await this.stalkerSession.makeAuthenticatedRequest(
playlist,
params
@@ -16,7 +16,12 @@ import { Store } from '@ngrx/store';
import { selectAllPlaylistsMeta } from 'm3u-state';
import { firstValueFrom, map } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import { Channel, PlaylistMeta } from 'shared-interfaces';
import {
Channel,
Playlist,
PlaylistMeta,
StalkerPortalItem,
} from 'shared-interfaces';
import {
buildFavoriteUid,
UnifiedFavoriteChannel,
@@ -24,6 +29,29 @@ import {
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
interface XtreamGlobalFavoriteRow {
readonly added_at?: string | null;
readonly id: number;
readonly playlist_id: string;
readonly playlist_name: string;
readonly position?: number | null;
readonly poster_url?: string | null;
readonly title: string;
readonly xtream_id: number;
}
type PlaylistWithChannels = Playlist & {
readonly playlist?: {
readonly items?: Channel[];
};
};
function isStalkerFavoriteItem(
value: string | StalkerPortalItem
): value is StalkerPortalItem {
return typeof value !== 'string';
}
@Injectable({ providedIn: 'root' })
export class GlobalFavoritesService {
private readonly store = inject(Store);
@@ -103,9 +131,14 @@ export class GlobalFavoritesService {
// Persist xtream positions to DB
const xtreamUpdates = channels
.filter((ch) => ch.sourceType === 'xtream' && ch.contentId != null)
.filter(
(
ch
): ch is UnifiedFavoriteChannel & { contentId: number } =>
ch.sourceType === 'xtream' && ch.contentId != null
)
.map((ch, index) => ({
content_id: ch.contentId!,
content_id: ch.contentId,
position: index,
}));
@@ -138,16 +171,16 @@ export class GlobalFavoritesService {
);
// Load full playlist to access channel items
let playlist: any;
let playlist: PlaylistWithChannels | undefined;
try {
playlist = await firstValueFrom(
playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(meta._id)
);
)) as PlaylistWithChannels | undefined;
} catch {
continue;
}
const items: Channel[] = playlist?.playlist?.items ?? [];
const items = playlist?.playlist?.items ?? [];
for (const ch of items) {
if (favoriteIds.has(ch.id) || favoriteIds.has(ch.url)) {
const sourceItemId = ch.url || ch.id;
@@ -173,9 +206,10 @@ export class GlobalFavoritesService {
private async getXtreamLiveFavorites(): Promise<UnifiedFavoriteChannel[]> {
if (!window.electron) return [];
let items: any[] = [];
let items: XtreamGlobalFavoriteRow[] = [];
try {
items = await this.dbService.getGlobalFavorites();
items =
(await this.dbService.getGlobalFavorites()) as XtreamGlobalFavoriteRow[];
} catch {
return [];
}
@@ -211,17 +245,17 @@ export class GlobalFavoritesService {
if (!meta.favorites?.length) continue;
// Fetch full playlist to get credentials
let playlist: any;
let playlist: Playlist | undefined;
try {
playlist = await firstValueFrom(
playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(meta._id)
);
)) as Playlist | undefined;
} catch {
continue;
}
const favorites = Array.isArray(playlist?.favorites)
? playlist.favorites
? playlist.favorites.filter(isStalkerFavoriteItem)
: [];
for (const fav of favorites) {
@@ -303,8 +337,9 @@ export class GlobalFavoritesService {
const unordered: UnifiedFavoriteChannel[] = [];
for (const ch of channels) {
if (orderMap.has(ch.uid)) {
ordered.push({ ...ch, position: orderMap.get(ch.uid)! });
const position = orderMap.get(ch.uid);
if (position != null) {
ordered.push({ ...ch, position });
} else {
unordered.push(ch);
}
@@ -1,37 +0,0 @@
import { Injectable, signal } from '@angular/core';
export interface SettingsNavItem {
id: string;
label: string;
icon: string;
}
@Injectable({ providedIn: 'root' })
export class SettingsContextService {
readonly sections = signal<SettingsNavItem[]>([]);
readonly activeSection = signal<string>('general');
readonly pendingScrollTarget = signal<string | null>(null);
setSections(items: SettingsNavItem[]): void {
this.sections.set(items);
}
setActiveSection(id: string): void {
this.activeSection.set(id);
}
navigateToSection(id: string): void {
this.activeSection.set(id);
this.pendingScrollTarget.set(id);
}
clearPendingScrollTarget(): void {
this.pendingScrollTarget.set(null);
}
reset(): void {
this.sections.set([]);
this.activeSection.set('general');
this.pendingScrollTarget.set(null);
}
}
@@ -23,7 +23,7 @@ interface WorkspaceContextRoute {
interface XtreamCategoryLike {
id?: number | string;
category_id?: string;
category_id?: number | string;
}
@Component({
@@ -29,7 +29,7 @@ import { FavoritesContextService } from '@iptvnator/portal/shared/util';
<app-category-view
[items]="ctx.categories()"
[selectedCategoryId]="ctx.selectedCategoryId()"
(categoryClicked)="onCategoryClicked($any($event))"
(categoryClicked)="onCategoryClicked($event)"
/>
</div>
`,
@@ -42,22 +42,25 @@ export class WorkspaceFavoritesContextPanelComponent {
const categories = this.ctx.categories();
if (!categories.length) return 'Filter by type';
const selected = categories.find(
(c: any) => c.category_id === this.ctx.selectedCategoryId()
) as any;
(c) => c.category_id === this.ctx.selectedCategoryId()
);
return selected?.category_name ?? 'Filter by type';
});
readonly countBadge = computed(() => {
const categories = this.ctx.categories();
const selected = categories.find(
(c: any) => c.category_id === this.ctx.selectedCategoryId()
) as any;
(c) => c.category_id === this.ctx.selectedCategoryId()
);
if (!selected || selected.count === undefined) return '';
return `${selected.count} ${selected.count === 1 ? 'item' : 'items'}`;
});
onCategoryClicked(item: { category_id?: string | number }): void {
const id = String(item.category_id ?? 'all');
onCategoryClicked(item: {
category_id?: string | number;
id?: string | number;
}): void {
const id = String(item.category_id ?? item.id ?? 'all');
this.ctx.setCategoryId(id);
}
}
@@ -2,7 +2,7 @@ import { Location } from '@angular/common';
import { Component, inject } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsContextService } from './settings-context.service';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
@Component({
selector: 'app-workspace-settings-context-panel',
@@ -1,18 +0,0 @@
import { InjectionToken } from '@angular/core';
import { PlaylistType } from 'components';
export interface WorkspaceAccountInfoData {
vodStreamsCount: number;
liveStreamsCount: number;
seriesCount: number;
}
export interface WorkspaceShellActions {
openAddPlaylistDialog(type: PlaylistType): void;
openGlobalSearch(initialQuery?: string): void;
openGlobalRecent(): void;
openAccountInfo(data: WorkspaceAccountInfoData): void;
}
export const WORKSPACE_SHELL_ACTIONS =
new InjectionToken<WorkspaceShellActions>('WORKSPACE_SHELL_ACTIONS');
@@ -14,16 +14,18 @@ import {
PlaylistsService,
SettingsStore,
} from 'services';
import { SettingsContextService } from '../workspace-context-panel/settings-context.service';
import {
SettingsContextService,
WORKSPACE_SHELL_ACTIONS,
} from '@iptvnator/workspace/shell/util';
import { WorkspaceShellComponent } from './workspace-shell.component';
import { WORKSPACE_SHELL_ACTIONS } from './workspace-shell-actions';
class MockXtreamStore {
readonly contentSortMode = signal<
'date-desc' | 'date-asc' | 'name-asc' | 'name-desc'
>('date-desc');
readonly getCategoryItemCounts = signal(new Map<number, number>());
readonly recentItems = signal<any[]>([]);
readonly recentItems = signal<unknown[]>([]);
setContentSortMode = jest.fn();
setSearchTerm = jest.fn();
@@ -33,7 +35,7 @@ class MockXtreamStore {
describe('WorkspaceShellComponent action matrix', () => {
beforeEach(() => {
(window as any).electron = { platform: 'darwin' };
window.electron = { platform: 'darwin' } as typeof window.electron;
const selectSignal = jest
.fn()
@@ -48,20 +48,20 @@ import {
PlaylistsService,
SettingsStore,
} from 'services';
import {
SettingsContextService,
WorkspaceAccountInfoData,
WORKSPACE_SHELL_ACTIONS,
} from '@iptvnator/workspace/shell/util';
import {
WorkspaceCommandItem,
WorkspaceCommandPaletteComponent,
WorkspaceCommandSelection,
} from '../workspace-command-palette/workspace-command-palette.component';
import { SettingsContextService } from '../workspace-context-panel/settings-context.service';
import { WorkspaceContextPanelComponent } from '../workspace-context-panel/workspace-context-panel.component';
import { WorkspaceFavoritesContextPanelComponent } from '../workspace-context-panel/workspace-favorites-context-panel.component';
import { WorkspaceSettingsContextPanelComponent } from '../workspace-context-panel/workspace-settings-context-panel.component';
import { WorkspaceSourcesFiltersPanelComponent } from '../workspace-sources-filters-panel/workspace-sources-filters-panel.component';
import {
WorkspaceAccountInfoData,
WORKSPACE_SHELL_ACTIONS,
} from './workspace-shell-actions';
interface WorkspaceContext {
provider: 'xtreams' | 'stalker' | 'playlists';
@@ -5,7 +5,7 @@ import { Store } from '@ngrx/store';
import { PlaylistType, RecentPlaylistsComponent } from 'components';
import { selectActiveTypeFilters, selectAllPlaylistsMeta } from 'm3u-state';
import { map } from 'rxjs';
import { WORKSPACE_SHELL_ACTIONS } from '../workspace-shell/workspace-shell-actions';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
@Component({
selector: 'app-workspace-sources',