From 8b5344ef262631813afa31facbf724c17a5b8de6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Apr 2026 10:11:22 +0200 Subject: [PATCH] feat: enhance DashboardDataService with loading states and global favorites handling --- .../src/lib/dashboard-data.service.spec.ts | 91 +++++++++++++++-- .../src/lib/dashboard-data.service.ts | 99 ++++++++++++++++++- 2 files changed, 181 insertions(+), 9 deletions(-) diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index b85e01e43..5845814dd 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -2,7 +2,10 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { selectAllPlaylistsMeta } from 'm3u-state'; +import { + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from 'm3u-state'; import { of } from 'rxjs'; import { DatabaseService, PlaylistsService } from 'services'; import { Playlist, PlaylistMeta } from 'shared-interfaces'; @@ -10,6 +13,7 @@ import { DashboardDataService } from './dashboard-data.service'; describe('DashboardDataService', () => { let service: DashboardDataService; + const playlistsLoadedSignal = signal(true); const playlistsSignal = signal([ { @@ -50,6 +54,9 @@ describe('DashboardDataService', () => { if (selector === selectAllPlaylistsMeta) { return playlistsSignal; } + if (selector === selectPlaylistsLoadingFlag) { + return playlistsLoadedSignal; + } return signal(null); }), dispatch: jest.fn(), @@ -58,7 +65,7 @@ describe('DashboardDataService', () => { const dbServiceMock = { getGlobalRecentlyAdded: jest.fn().mockResolvedValue([]), getGlobalRecentlyViewed: jest.fn().mockResolvedValue([]), - getGlobalFavorites: jest.fn().mockResolvedValue([]), + getAllGlobalFavorites: jest.fn().mockResolvedValue([]), removeFromFavorites: jest.fn().mockResolvedValue(undefined), removeRecentItem: jest.fn().mockResolvedValue(undefined), }; @@ -113,6 +120,7 @@ describe('DashboardDataService', () => { value: {} as Window['electron'], configurable: true, }); + playlistsLoadedSignal.set(true); playlistsServiceMock.getPlaylistById.mockClear(); playlistsServiceMock.getPlaylistById.mockReturnValue(of(playlistMock)); playlistsServiceMock.setFavorites.mockClear(); @@ -124,8 +132,8 @@ describe('DashboardDataService', () => { recentlyViewed: [], }) ); - dbServiceMock.getGlobalFavorites.mockClear(); - dbServiceMock.getGlobalFavorites.mockResolvedValue([]); + dbServiceMock.getAllGlobalFavorites.mockClear(); + dbServiceMock.getAllGlobalFavorites.mockResolvedValue([]); dbServiceMock.getGlobalRecentlyAdded.mockClear(); dbServiceMock.getGlobalRecentlyAdded.mockResolvedValue([]); dbServiceMock.getGlobalRecentlyViewed.mockClear(); @@ -186,6 +194,60 @@ describe('DashboardDataService', () => { ); }); + it('includes Xtream movies and series in global favorite items', async () => { + dbServiceMock.getAllGlobalFavorites.mockResolvedValue([ + { + id: 51, + category_id: 12, + title: 'Action Movie', + rating: '8.0', + added_at: '2026-02-02T10:00:00.000Z', + poster_url: 'https://example.com/movie.png', + xtream_id: 5001, + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + }, + { + id: 52, + category_id: 13, + title: 'Sci-Fi Series', + rating: '8.7', + added_at: '2026-02-03T10:00:00.000Z', + poster_url: 'https://example.com/series.png', + xtream_id: 5002, + type: 'series', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + }, + ]); + + await service.reloadGlobalFavorites(); + + expect(service.globalFavoriteItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 51, + title: 'Action Movie', + type: 'movie', + source: 'xtream', + }), + expect.objectContaining({ + id: 52, + title: 'Sci-Fi Series', + type: 'series', + source: 'xtream', + }), + ]) + ); + expect( + service.globalFavoriteItems().filter((item) => item.type === 'movie') + ).toHaveLength(1); + expect( + service.globalFavoriteItems().filter((item) => item.type === 'series') + ).toHaveLength(1); + }); + it('builds the M3U favorites route', async () => { await service.reloadGlobalFavorites(); const m3uItem = service @@ -199,6 +261,16 @@ describe('DashboardDataService', () => { 'm3u-1', 'favorites', ]); + expect(service.getGlobalFavoriteNavigationState(m3uItem!)).toEqual({ + openLiveCollectionItem: { + contentType: 'live', + sourceType: 'm3u', + playlistId: 'm3u-1', + itemId: 'channel-1', + title: 'Channel One', + imageUrl: 'https://example.com/logo-1.png', + }, + }); }); it('removes M3U favorites via PlaylistsService', async () => { @@ -292,7 +364,7 @@ describe('DashboardDataService', () => { expect(service.getRecentlyAddedNavigationState(item)).toBeUndefined(); }); - it('builds the M3U recent route with navigation state', () => { + it('builds the M3U recent route with collection auto-open state', () => { const m3uItem = service .globalRecentItems() .find((item) => item.source === 'm3u'); @@ -305,7 +377,14 @@ describe('DashboardDataService', () => { 'recent', ]); expect(service.getRecentItemNavigationState(m3uItem!)).toEqual({ - openRecentChannelUrl: 'https://example.com/stream-1.m3u8', + openLiveCollectionItem: { + contentType: 'live', + sourceType: 'm3u', + playlistId: 'm3u-1', + itemId: 'https://example.com/stream-1.m3u8', + title: 'Channel One', + imageUrl: 'https://example.com/logo-1.png', + }, }); }); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index d299c4a2f..9c6856f7a 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -1,10 +1,18 @@ -import { Injectable, NgZone, computed, inject, signal } from '@angular/core'; +import { + Injectable, + NgZone, + computed, + effect, + inject, + signal, +} from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions, selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, } from 'm3u-state'; import { firstValueFrom, startWith } from 'rxjs'; import { @@ -66,7 +74,34 @@ export class DashboardDataService { [] ); private readonly m3uGlobalFavorites = signal([]); + private readonly globalRecentLoadingState = signal(true); + private readonly globalRecentLoadedState = signal(false); + private readonly globalRecentDbLoadedState = signal(!window.electron); + private readonly globalFavoritesLoadingState = signal(true); + private readonly globalFavoritesLoadedState = signal(false); readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); + readonly playlistsLoaded = this.store.selectSignal(selectPlaylistsLoadingFlag); + readonly globalRecentLoading = this.globalRecentLoadingState.asReadonly(); + readonly globalRecentLoaded = this.globalRecentLoadedState.asReadonly(); + readonly globalFavoritesLoading = + this.globalFavoritesLoadingState.asReadonly(); + readonly globalFavoritesLoaded = this.globalFavoritesLoadedState.asReadonly(); + + private readonly playlistFavoritesReloadKey = computed(() => { + if (!this.playlistsLoaded()) { + return null; + } + + return this.playlists() + .map((playlist) => + [ + playlist._id, + playlist.serverUrl ? 'xtream' : playlist.macAddress ? 'stalker' : 'm3u', + JSON.stringify(playlist.favorites ?? []), + ].join('::') + ) + .join('|'); + }); readonly playlistBackedGlobalRecentItems = computed( () => { @@ -112,6 +147,21 @@ export class DashboardDataService { constructor() { void this.reloadGlobalFavorites(); + + effect(() => { + this.playlistFavoritesReloadKey(); + if (!this.playlistsLoaded()) { + return; + } + + void this.refreshPlaylistBackedGlobalFavorites(); + }); + + effect(() => { + this.playlistsLoaded(); + this.finishInitialGlobalRecentLoadIfReady(); + }); + if (window.electron) { void this.reloadGlobalRecentItems(); } @@ -140,8 +190,14 @@ export class DashboardDataService { readonly quickRecent = computed(() => this.recentPlaylists().slice(0, 4)); async reloadGlobalRecentItems(): Promise { + if (!this.globalRecentLoaded()) { + this.globalRecentLoadingState.set(true); + } + if (!window.electron) { this.xtreamGlobalRecentItems.set([]); + this.globalRecentDbLoadedState.set(true); + this.finishInitialGlobalRecentLoadIfReady(); return; } @@ -157,12 +213,19 @@ export class DashboardDataService { err ); this.ngZone.run(() => this.xtreamGlobalRecentItems.set([])); + } finally { + this.globalRecentDbLoadedState.set(true); + this.finishInitialGlobalRecentLoadIfReady(); } } async reloadGlobalFavorites(): Promise { + if (!this.globalFavoritesLoaded()) { + this.globalFavoritesLoadingState.set(true); + } + const xtreamReload = this.reloadXtreamGlobalFavorites(); - const m3uReload = this.reloadM3uGlobalFavorites(); + const m3uReload = this.refreshPlaylistBackedGlobalFavorites(); await Promise.all([xtreamReload, m3uReload]); } @@ -184,11 +247,12 @@ export class DashboardDataService { private async reloadXtreamGlobalFavorites(): Promise { if (!window.electron) { this.xtreamGlobalFavorites.set([]); + this.finishInitialGlobalFavoritesLoadIfReady(); return; } try { - const favorites = await this.dbService.getGlobalFavorites(); + const favorites = await this.dbService.getAllGlobalFavorites(); const normalized = favorites.map((item) => mapDbFavoriteToItem(item) ); @@ -199,9 +263,16 @@ export class DashboardDataService { err ); this.ngZone.run(() => this.xtreamGlobalFavorites.set([])); + } finally { + this.finishInitialGlobalFavoritesLoadIfReady(); } } + private async refreshPlaylistBackedGlobalFavorites(): Promise { + await this.reloadM3uGlobalFavorites(); + this.finishInitialGlobalFavoritesLoadIfReady(); + } + private async reloadM3uGlobalFavorites(): Promise { const m3uPlaylists = this.playlists().filter( (playlist) => @@ -234,6 +305,28 @@ export class DashboardDataService { this.ngZone.run(() => this.m3uGlobalFavorites.set(flattenedFavorites)); } + private finishInitialGlobalFavoritesLoadIfReady(): void { + if (!this.playlistsLoaded()) { + return; + } + + this.ngZone.run(() => { + this.globalFavoritesLoadedState.set(true); + this.globalFavoritesLoadingState.set(false); + }); + } + + private finishInitialGlobalRecentLoadIfReady(): void { + if (!this.playlistsLoaded() || !this.globalRecentDbLoadedState()) { + return; + } + + this.ngZone.run(() => { + this.globalRecentLoadedState.set(true); + this.globalRecentLoadingState.set(false); + }); + } + isTypeInKind( type: PortalActivityType, kind: DashboardContentKind