refactor(portal): split collection data access (#953)

This commit is contained in:
4gray authored and GitHub committed 2026-05-19 00:01:34 +02:00
1 parent 118d4fce32
commit 20e7322353
28 files changed
+185 -236

No files matched your search

@@ -76,3 +76,9 @@ buildable dependency paths to `dist/` during `@nx/js:tsc` builds.
If a change needs a dependency in the opposite direction, move the shared
contract into a lower-level library instead of weakening boundaries.
Portal collection orchestration that reads/writes favorites, recent items, live
playback, or EPG data belongs in `libs/portal/shared/data-access`, not
`libs/portal/shared/util`. That keeps pure collection helpers importable by
Xtream/Stalker data-access libraries while allowing shared UI to use
provider-specific collection services without creating cycles.
@@ -0,0 +1,21 @@
export default {
displayName: 'portal-shared-data-access',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/portal/shared/data-access',
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": "portal-shared-data-access",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/portal/shared/data-access/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:portal", "domain:portal-shared", "type:data-access"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/portal/shared/data-access/jest.config.ts",
"tsConfig": "libs/portal/shared/data-access/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -0,0 +1 @@
export * from './lib/collection';
@@ -0,0 +1,3 @@
export * from './unified-favorites-data.service';
export * from './unified-recent-data.service';
export * from './stream-resolver.service';
@@ -7,7 +7,7 @@ import {
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { DataService, PlaylistsService } from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { UnifiedCollectionItem } from './unified-collection-item.interface';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import {
ResolvedLiveCollectionDetail,
StreamResolverService,
@@ -15,7 +15,7 @@ import {
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { UnifiedCollectionItem } from './unified-collection-item.interface';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
type PlaylistWithChannels = Playlist & {
readonly playlist?: { readonly items?: Channel[] };
@@ -9,7 +9,7 @@ import {
PlaylistMeta,
StalkerPortalItem,
} from '@iptvnator/shared/interfaces';
import { UnifiedCollectionItem } from './unified-collection-item.interface';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { UnifiedFavoritesDataService } from './unified-favorites-data.service';
describe('UnifiedFavoritesDataService', () => {
@@ -19,14 +19,12 @@ import {
import {
buildCollectionUid,
buildXtreamCollectionUid,
UnifiedCollectionItem,
} from './unified-collection-item.interface';
import { CollectionScope } from './scope-toggle.service';
import {
CollectionScope,
isStalkerItem,
UnifiedCollectionItem,
xtreamContentType,
XtreamFavoriteRow,
} from './collection-helpers';
} from '@iptvnator/portal/shared/util';
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
@@ -346,18 +344,22 @@ export class UnifiedFavoritesDataService {
private async clearXtreamFavorites(
items: UnifiedCollectionItem[]
): Promise<void> {
if (!window.electron) {
const electron = window.electron;
if (!electron) {
return;
}
await Promise.all(
items
.filter((item) => item.contentId != null)
.filter(
(
item
): item is UnifiedCollectionItem & {
readonly contentId: number;
} => item.contentId != null
)
.map((item) =>
window.electron!.dbRemoveFavorite(
item.contentId!,
item.playlistId
)
electron.dbRemoveFavorite(item.contentId, item.playlistId)
)
);
}
@@ -679,9 +681,11 @@ export class UnifiedFavoritesDataService {
const unordered: UnifiedCollectionItem[] = [];
for (const item of items) {
const pos = orderMap.get(item.uid);
pos != null
? ordered.push({ ...item, position: pos })
: unordered.push(item);
if (pos != null) {
ordered.push({ ...item, position: pos });
} else {
unordered.push(item);
}
}
ordered.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
unordered.sort(
@@ -693,14 +697,18 @@ export class UnifiedFavoritesDataService {
}
private buildXtreamPositionUpdates(items: UnifiedCollectionItem[]) {
return items
.filter(
(item) => item.sourceType === 'xtream' && item.contentId != null
)
.map((item, index) => ({
content_id: item.contentId!,
position: index,
}));
const updates: { content_id: number; position: number }[] = [];
for (const item of items) {
if (item.sourceType === 'xtream' && item.contentId != null) {
updates.push({
content_id: item.contentId,
position: updates.length,
});
}
}
return updates;
}
private groupItemsByPlaylist(
@@ -8,7 +8,7 @@ import {
Playlist,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import { UnifiedCollectionItem } from './unified-collection-item.interface';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { UnifiedRecentDataService } from './unified-recent-data.service';
describe('UnifiedRecentDataService', () => {
@@ -22,10 +22,10 @@ import {
import {
buildCollectionUid,
buildXtreamCollectionUid,
CollectionScope,
UnifiedCollectionItem,
} from './unified-collection-item.interface';
import { CollectionScope } from './scope-toggle.service';
import { xtreamContentType } from './collection-helpers';
xtreamContentType,
} from '@iptvnator/portal/shared/util';
type PlaylistWithChannels = Playlist & {
readonly playlist?: { readonly items?: Channel[] };
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
@@ -0,0 +1,31 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.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"
]
}
-1
View File
@@ -6,7 +6,6 @@ export * from './lib/components/global-favorites-list/global-favorites-list.comp
export * from './lib/components/grid-list/grid-list.component';
export * from './lib/components/playlist-error-view/playlist-error-view.component';
export { ProgressCapsuleComponent } from '@iptvnator/ui/components';
export * from './lib/components/portal-collection-live-shell/portal-collection-live-shell.component';
export * from './lib/components/portal-collection-shell/portal-collection-shell.component';
export * from './lib/components/portal-empty-state/portal-empty-state.component';
export * from './lib/components/search-form/search-form.component';
@@ -1,55 +0,0 @@
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div class="sidebar-header category-content-header">
<div class="category-meta">
<h2 class="category-title">{{ title() }}</h2>
<div class="category-subtitle">{{ subtitle() }}</div>
</div>
<ng-content select="[liveHeaderActions]" />
</div>
<ng-content select="[liveChannels]" />
</div>
<div class="content-container">
@if (hasSelection()) {
@if (streamUrl() && isEmbeddedPlayer()) {
<div class="video-player">
<app-web-player-view [streamUrl]="streamUrl()" />
</div>
}
<div class="epg">
<div class="epg-content">
@if (epgItems().length > 0) {
<app-epg-view [epgItems]="epgItems()" />
}
@if (isLoadingEpg()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
}
@if (hasMoreEpg() && !isLoadingEpg()) {
<div class="load-more-epg">
<button mat-button (click)="onLoadMoreEpg()">
{{ 'CHANNELS.LOAD_MORE' | translate }}
</button>
</div>
}
</div>
</div>
} @else {
<app-portal-empty-state
icon="live_tv"
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
/>
}
</div>
@@ -1,81 +0,0 @@
@use '@angular/material' as mat;
@use '../../styles/portal-layout' as portal;
@include portal.live-layout;
// ─── Sidebar panel header ─────────────────────────────────────────────────────
.category-content-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 8px 12px;
min-height: 48px;
flex-shrink: 0;
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-separator);
app-region: drag;
// Own buttons (direct children scoped to this component)
button {
@include mat.form-field-density(-5);
app-region: no-drag;
}
// Projected buttons (liveHeaderActions slot) — view encapsulation
// prevents the rule above from reaching them, so ng-deep is required.
::ng-deep button {
app-region: no-drag;
}
}
.category-meta {
min-width: 0;
display: flex;
align-items: baseline;
gap: 8px;
flex: 1;
}
// ─── Title + count chip — now aligned with category-content-view ─────────────
.category-title {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Informational badge — primary-tinted pill with definition ring
.category-subtitle {
white-space: nowrap;
flex-shrink: 0;
font-size: 0.70rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: capitalize;
line-height: 1;
color: var(--mat-sys-primary);
background: color-mix(in srgb, var(--mat-sys-primary) 10%, var(--mat-sys-surface-container-high));
padding: 4px 10px;
border-radius: 999px;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--mat-sys-primary) 30%, transparent),
0 1px 3px rgba(0, 0, 0, 0.18);
}
// ─── EPG controls ─────────────────────────────────────────────────────────────
.loading-indicator {
display: flex;
justify-content: center;
padding: 16px;
}
.load-more-epg {
display: flex;
justify-content: center;
padding: 8px 0;
}
@@ -1,47 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from '@iptvnator/ui/components';
import { EpgItem } from '@iptvnator/shared/interfaces';
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
import { EpgViewComponent } from '@iptvnator/ui/shared-portals';
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
@Component({
selector: 'app-portal-collection-live-shell',
imports: [
EpgViewComponent,
MatButtonModule,
MatProgressSpinnerModule,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
WebPlayerViewComponent,
],
templateUrl: './portal-collection-live-shell.component.html',
styleUrl: './portal-collection-live-shell.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PortalCollectionLiveShellComponent {
readonly title = input<string>('Favorites');
readonly subtitle = input<string>('Live TV');
readonly count = input<number>(0);
readonly streamUrl = input<string>('');
readonly isEmbeddedPlayer = input<boolean>(true);
readonly hasSelection = input<boolean>(false);
readonly epgItems = input<EpgItem[]>([]);
readonly isLoadingEpg = input<boolean>(false);
readonly hasMoreEpg = input<boolean>(false);
readonly loadMoreEpgClicked = output<void>();
onLoadMoreEpg(): void {
this.loadMoreEpgClicked.emit();
}
}
@@ -19,10 +19,12 @@ import {
OPEN_COLLECTION_DETAIL_STATE_KEY,
ScopeToggleService,
UnifiedCollectionItem,
UnifiedFavoritesDataService,
UnifiedRecentDataService,
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import {
UnifiedFavoritesDataService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state';
import { BehaviorSubject } from 'rxjs';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
@@ -48,10 +48,12 @@ import {
ScopeToggleService,
STALKER_RETURN_TO_STATE_KEY,
UnifiedCollectionItem,
UnifiedFavoritesDataService,
UnifiedRecentDataService,
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import {
UnifiedFavoritesDataService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state';
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
@@ -40,11 +40,13 @@ import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
PORTAL_PLAYER,
FavoritesChannelSortMode,
StreamResolverService,
UnifiedCollectionItem,
UnifiedFavoriteChannel,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/util';
import {
StreamResolverService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
@Directive({
selector: '[appResizable]',
@@ -22,13 +22,15 @@ import {
OpenLiveCollectionItemState,
PORTAL_PLAYER,
persistLiveEpgPanelState,
ResolvedLiveCollectionDetail,
restoreLiveEpgPanelState,
StreamResolverService,
UnifiedCollectionItem,
UnifiedFavoriteChannel,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/util';
import {
ResolvedLiveCollectionDetail,
StreamResolverService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
import {
EpgDateNavigationDirection,
EpgListComponent,
@@ -1,6 +1,4 @@
export * from './unified-collection-item.interface';
export * from './unified-favorite-channel.interface';
export * from './collection-helpers';
export * from './scope-toggle.service';
export * from './unified-favorites-data.service';
export * from './unified-recent-data.service';
export * from './stream-resolver.service';
@@ -1,19 +1,22 @@
import { Injectable, signal } from '@angular/core';
export type CollectionScope = 'playlist' | 'all';
type CollectionScopeSignal = ReturnType<typeof signal<CollectionScope>>;
const STORAGE_PREFIX = 'collection-scope-';
@Injectable({ providedIn: 'root' })
export class ScopeToggleService {
private readonly scopes = new Map<string, ReturnType<typeof signal<CollectionScope>>>();
private readonly scopes = new Map<string, CollectionScopeSignal>();
getScope(viewKey: string): ReturnType<typeof signal<CollectionScope>> {
if (!this.scopes.has(viewKey)) {
getScope(viewKey: string): CollectionScopeSignal {
let scope = this.scopes.get(viewKey);
if (!scope) {
const persisted = this.readFromStorage(viewKey);
this.scopes.set(viewKey, signal<CollectionScope>(persisted));
scope = signal<CollectionScope>(persisted);
this.scopes.set(viewKey, scope);
}
return this.scopes.get(viewKey)!;
return scope;
}
setScope(viewKey: string, value: CollectionScope): void {
@@ -7,15 +7,11 @@ import {
getCollectionViewState,
getOpenCollectionDetailItemState,
getOpenLiveCollectionItemState,
getOpenStalkerItemState,
getRecentItemNavigation,
getStalkerReturnToState,
getUnifiedCollectionNavigation,
OPEN_COLLECTION_DETAIL_STATE_KEY,
matchesOpenLiveCollectionItem,
OPEN_LIVE_COLLECTION_ITEM_STATE_KEY,
OPEN_STALKER_ITEM_STATE_KEY,
STALKER_RETURN_TO_STATE_KEY,
} from './workspace-portal-navigation';
describe('workspace-portal-navigation', () => {
+1 -2
View File
@@ -1,6 +1,5 @@
// ─── Shared mixin for live-stream portal layouts ──────────────────────────────
// Used by: xtream live-stream-layout, stalker live-stream-layout,
// portal-collection-live-shell
// Used by: xtream live-stream-layout and stalker live-stream-layout.
//
// Include this mixin in a component's SCSS to get the shared flex host+sidebar
// skeleton, then add only the component-specific rules underneath.
+3
View File
@@ -24,6 +24,9 @@
"@iptvnator/portal/shared/ui": [
"libs/portal/shared/ui/src/index.ts"
],
"@iptvnator/portal/shared/data-access": [
"libs/portal/shared/data-access/src/index.ts"
],
"@iptvnator/portal/shared/util": [
"libs/portal/shared/util/src/index.ts"
],