From ce251e5f9be27f8106b3b6ea56c4cba9d98808bf Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 01:39:58 +0200 Subject: [PATCH] refactor(downloads): isolate library navigation --- ...ownload-library-navigation.service.spec.ts | 404 ++++++++++++++++++ .../download-library-navigation.service.ts | 210 +++++++++ .../feature/src/lib/downloads.component.ts | 203 +-------- tools/eslint/max-lines-baseline.mjs | 1 - 4 files changed, 624 insertions(+), 194 deletions(-) create mode 100644 libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts diff --git a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts new file mode 100644 index 000000000..9e57c2687 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts @@ -0,0 +1,404 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { + DatabaseService, + type DownloadItem, + PlaylistsService, +} from '@iptvnator/services'; +import type { Playlist, StalkerPortalItem } from '@iptvnator/shared/interfaces'; +import { DownloadLibraryNavigationService } from './download-library-navigation.service'; + +const PLAYLIST_ID = 'playlist-a'; +const XTREAM_PLAYLIST = { + _id: PLAYLIST_ID, + title: 'Xtream source', + importDate: '2026-07-30', + lastUsage: '2026-07-30', + count: 0, + autoRefresh: false, + portalUrl: 'https://xtream.example.test', +} satisfies Playlist; +const STALKER_PLAYLIST = { + ...XTREAM_PLAYLIST, + title: 'Stalker source', + portalUrl: 'https://stalker.example.test', + macAddress: '00:1A:79:12:34:56', +} satisfies Playlist; + +type RouterFake = jest.Mocked>; +type DatabaseFake = jest.Mocked< + Pick +>; +type PlaylistsFake = jest.Mocked< + Pick< + PlaylistsService, + 'getPlaylistById' | 'getPortalRecentlyViewed' + > +>; + +function download(overrides: Partial = {}): DownloadItem { + return { + id: 1, + playlistId: PLAYLIST_ID, + xtreamId: 41, + contentType: 'vod', + title: 'Downloaded title', + url: 'https://media.example.test/video', + posterUrl: 'https://media.example.test/poster.jpg', + status: 'completed', + ...overrides, + }; +} + +describe('DownloadLibraryNavigationService', () => { + let navigation: DownloadLibraryNavigationService; + let router: RouterFake; + let db: DatabaseFake; + let playlists: PlaylistsFake; + + beforeEach(() => { + router = { + navigate: jest.fn().mockResolvedValue(true), + }; + db = { + getContentByXtreamId: jest.fn().mockResolvedValue(null), + }; + playlists = { + getPlaylistById: jest.fn().mockReturnValue(of(XTREAM_PLAYLIST)), + getPortalRecentlyViewed: jest.fn().mockReturnValue(of([])), + }; + + TestBed.configureTestingModule({ + providers: [ + DownloadLibraryNavigationService, + { provide: Router, useValue: router }, + { provide: DatabaseService, useValue: db }, + { provide: PlaylistsService, useValue: playlists }, + ], + }); + + navigation = TestBed.inject(DownloadLibraryNavigationService); + }); + + describe('canOpen', () => { + const availablePlaylistIds = new Set([PLAYLIST_ID]); + + it('accepts positive safe VOD, series, and episode fallback IDs', () => { + expect( + navigation.canOpen( + download({ xtreamId: Number.MAX_SAFE_INTEGER }), + availablePlaylistIds + ) + ).toBe(true); + expect( + navigation.canOpen( + download({ + contentType: 'episode', + xtreamId: 9, + seriesXtreamId: 72, + }), + availablePlaylistIds + ) + ).toBe(true); + expect( + navigation.canOpen( + download({ + contentType: 'episode', + xtreamId: 9, + seriesXtreamId: undefined, + }), + availablePlaylistIds + ) + ).toBe(true); + }); + + it('rejects an item whose source playlist is absent', () => { + expect(navigation.canOpen(download(), new Set(['playlist-b']))).toBe( + false + ); + }); + + it.each([ + ['missing', undefined as unknown as number], + ['NaN', Number.NaN], + ['positive infinity', Number.POSITIVE_INFINITY], + ['negative infinity', Number.NEGATIVE_INFINITY], + ['zero', 0], + ['negative', -1], + ['fractional', 4.5], + ['unsafe', Number.MAX_SAFE_INTEGER + 1], + ])('rejects a %s VOD target ID', (_label, xtreamId) => { + expect( + navigation.canOpen( + download({ xtreamId }), + availablePlaylistIds + ) + ).toBe(false); + }); + + it('does not fall back when an episode has a malformed series ID', () => { + expect( + navigation.canOpen( + download({ + contentType: 'episode', + xtreamId: 9, + seriesXtreamId: 0, + }), + availablePlaylistIds + ) + ).toBe(false); + }); + }); + + it.each([ + ['missing', undefined as unknown as number], + ['zero', 0], + ['negative', -8], + ['fractional', 8.5], + ['unsafe', Number.MAX_SAFE_INTEGER + 1], + ])('does not resolve or navigate a %s target ID', async (_label, id) => { + await expect(navigation.open(download({ xtreamId: id }))).resolves.toBe( + false + ); + expect(playlists.getPlaylistById).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('opens an Xtream VOD at its category detail route', async () => { + db.getContentByXtreamId.mockResolvedValue({ + category_id: 7, + } as never); + + await expect(navigation.open(download())).resolves.toBe(true); + + expect(db.getContentByXtreamId).toHaveBeenCalledWith(41, PLAYLIST_ID); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + PLAYLIST_ID, + 'vod', + '7', + '41', + ]); + }); + + it('opens an Xtream episode at its series detail route', async () => { + const item = download({ + contentType: 'episode', + xtreamId: 511, + seriesXtreamId: 93, + }); + db.getContentByXtreamId.mockResolvedValue({ + category_id: '12', + } as never); + + await expect(navigation.open(item)).resolves.toBe(true); + + expect(db.getContentByXtreamId).toHaveBeenCalledWith(93, PLAYLIST_ID); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + PLAYLIST_ID, + 'series', + '12', + '93', + ]); + }); + + it.each([ + ['missing', {}], + ['null', { category_id: null }], + ])( + 'falls back to the Xtream collection route for a %s category', + async (_label, content) => { + db.getContentByXtreamId.mockResolvedValue(content as never); + + await expect(navigation.open(download())).resolves.toBe(true); + + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + PLAYLIST_ID, + 'vod', + ]); + } + ); + + it('returns false when the source playlist is missing', async () => { + playlists.getPlaylistById.mockReturnValue( + of(undefined as unknown as Playlist) + ); + + await expect(navigation.open(download())).resolves.toBe(false); + + expect(db.getContentByXtreamId).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('returns false when the source playlist lookup fails', async () => { + playlists.getPlaylistById.mockReturnValue( + throwError(() => new Error('lookup failed')) + ); + + await expect(navigation.open(download())).resolves.toBe(false); + + expect(db.getContentByXtreamId).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it.each(['id', 'movie_id', 'series_id', 'stream_id'] as const)( + 'matches a Stalker recent item by normalized %s', + async (idKey) => { + playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); + const recent = { + [idKey]: '41:archive', + category_id: 'series', + title: 'Recent title', + name: 'Recent name', + preserved: 'yes', + } satisfies StalkerPortalItem & { preserved: string }; + playlists.getPortalRecentlyViewed.mockReturnValue(of([recent])); + + await expect(navigation.open(download())).resolves.toBe(true); + + const expectedId = + idKey === 'stream_id' ? '41' : recent[idKey]; + expect(router.navigate).toHaveBeenCalledWith( + ['/workspace', 'stalker', PLAYLIST_ID, 'recent'], + { + state: { + openRecentItem: { + ...recent, + id: expectedId, + category_id: 'series', + title: 'Recent title', + name: 'Recent name', + }, + }, + } + ); + } + ); + + it('preserves a matched Stalker item and fills its normalized display fields', async () => { + playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); + const recent = { + series_id: '41:episode', + category_id: 'movie', + o_name: 'Portal original name', + preserved: { token: 1 }, + } satisfies StalkerPortalItem & { + preserved: { token: number }; + }; + playlists.getPortalRecentlyViewed.mockReturnValue(of([recent])); + + await expect(navigation.open(download())).resolves.toBe(true); + + expect(router.navigate).toHaveBeenCalledWith( + ['/workspace', 'stalker', PLAYLIST_ID, 'recent'], + { + state: { + openRecentItem: { + ...recent, + id: '41:episode', + category_id: 'vod', + title: 'Downloaded title', + name: 'Portal original name', + }, + }, + } + ); + }); + + it.each([ + ['VOD', 'vod', 'vod'], + ['SERIES', 'series', 'vod'], + ['ItV', 'itv', 'vod'], + ['radio', 'vod', 'vod'], + ['unknown', 'series', 'episode'], + ] as const)( + 'normalizes Stalker category %s to %s', + async (categoryId, expected, contentType) => { + playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); + playlists.getPortalRecentlyViewed.mockReturnValue( + of([ + { + id: '41', + category_id: categoryId, + }, + ]) + ); + + await navigation.open(download({ contentType })); + + expect(router.navigate).toHaveBeenCalledWith( + ['/workspace', 'stalker', PLAYLIST_ID, 'recent'], + { + state: { + openRecentItem: expect.objectContaining({ + category_id: expected, + }), + }, + } + ); + } + ); + + it('uses download metadata when no Stalker recent item matches', async () => { + playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); + playlists.getPortalRecentlyViewed.mockReturnValue( + of([{ id: '999', category_id: 'vod' }]) + ); + + await expect(navigation.open(download())).resolves.toBe(true); + + expect(router.navigate).toHaveBeenCalledWith( + ['/workspace', 'stalker', PLAYLIST_ID, 'recent'], + { + state: { + openRecentItem: { + id: '41', + category_id: 'vod', + title: 'Downloaded title', + name: 'Downloaded title', + o_name: 'Downloaded title', + cover: 'https://media.example.test/poster.jpg', + logo: 'https://media.example.test/poster.jpg', + }, + }, + } + ); + }); + + it('uses episode download metadata when Stalker recent loading fails', async () => { + playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); + playlists.getPortalRecentlyViewed.mockReturnValue( + throwError(() => new Error('recent failed')) + ); + const item = download({ + contentType: 'episode', + xtreamId: 501, + seriesXtreamId: 72, + }); + + await expect(navigation.open(item)).resolves.toBe(true); + + expect(router.navigate).toHaveBeenCalledWith( + ['/workspace', 'stalker', PLAYLIST_ID, 'recent'], + { + state: { + openRecentItem: { + id: '72', + category_id: 'series', + title: 'Downloaded title', + name: 'Downloaded title', + o_name: 'Downloaded title', + cover: 'https://media.example.test/poster.jpg', + logo: 'https://media.example.test/poster.jpg', + }, + }, + } + ); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts new file mode 100644 index 000000000..baeb9959e --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts @@ -0,0 +1,210 @@ +import { inject, Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { firstValueFrom } from 'rxjs'; +import { + DatabaseService, + type DownloadItem, + PlaylistsService, +} from '@iptvnator/services'; +import type { StalkerPortalItem } from '@iptvnator/shared/interfaces'; + +type PortalSource = 'xtream' | 'stalker'; +type StalkerCategory = 'vod' | 'series' | 'itv'; +type StalkerFallbackCategory = Exclude; + +@Injectable() +export class DownloadLibraryNavigationService { + private readonly router = inject(Router); + private readonly db = inject(DatabaseService); + private readonly playlists = inject(PlaylistsService); + + canOpen( + item: DownloadItem, + availablePlaylistIds: ReadonlySet + ): boolean { + return ( + availablePlaylistIds.has(item.playlistId) && + this.targetId(item) !== null + ); + } + + async open(item: DownloadItem): Promise { + const targetId = this.targetId(item); + if (targetId === null) { + return false; + } + + const source = await this.resolveSourceType(item.playlistId); + if (source === null) { + return false; + } + + if (source === 'xtream') { + await this.openXtreamItem(item, targetId); + } else { + await this.openStalkerItem(item, targetId); + } + + return true; + } + + private targetId(item: DownloadItem): number | null { + const id = + item.contentType === 'episode' + ? (item.seriesXtreamId ?? item.xtreamId) + : item.xtreamId; + + return Number.isSafeInteger(id) && id > 0 ? id : null; + } + + private route( + source: PortalSource, + playlistId: string, + segments: Array + ): Array { + return [ + '/workspace', + source === 'stalker' ? 'stalker' : 'xtreams', + playlistId, + ...segments, + ]; + } + + private async resolveSourceType( + playlistId: string + ): Promise { + try { + const playlist = await firstValueFrom( + this.playlists.getPlaylistById(playlistId) + ); + if (!playlist) { + return null; + } + + return playlist.portalUrl && playlist.macAddress + ? 'stalker' + : 'xtream'; + } catch { + return null; + } + } + + private async openXtreamItem( + item: DownloadItem, + targetId: number + ): Promise { + const contentType = + item.contentType === 'episode' ? 'series' : 'vod'; + const content = await this.db.getContentByXtreamId( + targetId, + item.playlistId + ); + const categoryId = content?.category_id; + const segments = + categoryId === null || categoryId === undefined + ? [contentType] + : [contentType, String(categoryId), String(targetId)]; + + await this.router.navigate( + this.route('xtream', item.playlistId, segments) + ); + } + + private normalizePortalItemId(value: unknown): string { + const raw = String(value ?? '').trim(); + return raw.includes(':') ? raw.split(':')[0] : raw; + } + + private stalkerCategory( + value: unknown, + fallback: StalkerFallbackCategory + ): StalkerCategory { + const normalized = String(value ?? '').toLowerCase(); + if (normalized === 'movie') { + return 'vod'; + } + + return normalized === 'vod' || + normalized === 'series' || + normalized === 'itv' + ? normalized + : fallback; + } + + private findMatchingStalkerRecentItem( + items: readonly StalkerPortalItem[], + targetId: number + ): StalkerPortalItem | undefined { + const expectedId = String(targetId); + + return items.find((item) => + [item.id, item.movie_id, item.series_id, item.stream_id].some( + (candidate) => + this.normalizePortalItemId(candidate) === expectedId + ) + ); + } + + private async stalkerOpenState( + item: DownloadItem, + targetId: number, + fallback: StalkerFallbackCategory + ): Promise> { + try { + const recent = await firstValueFrom( + this.playlists.getPortalRecentlyViewed(item.playlistId) + ); + const matched = this.findMatchingStalkerRecentItem( + recent, + targetId + ); + + if (matched) { + return { + ...matched, + id: + matched.id ?? + matched.series_id ?? + matched.movie_id ?? + String(targetId), + category_id: this.stalkerCategory( + matched.category_id, + fallback + ), + title: matched.title ?? item.title, + name: matched.name ?? matched.o_name ?? item.title, + }; + } + } catch { + // Persisted download metadata is enough to open the recent route. + } + + return { + id: String(targetId), + category_id: fallback, + title: item.title, + name: item.title, + o_name: item.title, + cover: item.posterUrl, + logo: item.posterUrl, + }; + } + + private async openStalkerItem( + item: DownloadItem, + targetId: number + ): Promise { + const fallback = + item.contentType === 'episode' ? 'series' : 'vod'; + const openRecentItem = await this.stalkerOpenState( + item, + targetId, + fallback + ); + + await this.router.navigate( + this.route('stalker', item.playlistId, ['recent']), + { state: { openRecentItem } } + ); + } +} diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.ts b/libs/portal/downloads/feature/src/lib/downloads.component.ts index ab84c0c7b..c57ac4daf 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.ts @@ -15,9 +15,8 @@ import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { DialogService } from '@iptvnator/ui/components'; -import { firstValueFrom, map, startWith } from 'rxjs'; +import { map, startWith } from 'rxjs'; import { - DatabaseService, type DownloadItem, DownloadsService, PlaylistsService, @@ -32,8 +31,8 @@ import { } from '@iptvnator/portal/shared/util'; import { PortalCollectionContextService } from '@iptvnator/portal/shared/util'; import { Playlist } from '@iptvnator/shared/interfaces'; +import { DownloadLibraryNavigationService } from './download-library-navigation.service'; -type PortalSource = 'xtream' | 'stalker'; const DOWNLOAD_COLLECTION_LABELS = { all: 'All', movie: 'Movies', @@ -49,6 +48,7 @@ const DOWNLOAD_COLLECTION_LABELS = { '../../../../shared/ui/src/lib/styles/portal-sidebar.scss', ], changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DownloadLibraryNavigationService], imports: [ EmptyStateComponent, MatButtonModule, @@ -61,8 +61,10 @@ const DOWNLOAD_COLLECTION_LABELS = { export class DownloadsComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly dbService = inject(DatabaseService); private readonly playlistsService = inject(PlaylistsService); + private readonly libraryNavigation = inject( + DownloadLibraryNavigationService + ); private readonly collectionCtx = inject(PortalCollectionContextService); private readonly dialogService = inject(DialogService); private readonly translate = inject(TranslateService); @@ -373,10 +375,9 @@ export class DownloadsComponent { } isItemNavigable(item: DownloadItem): boolean { - return ( - !!item.playlistId && - this.hasSourcePlaylist(item) && - this.getTargetContentId(item) !== null + return this.libraryNavigation.canOpen( + item, + this.availablePlaylistIds() ); } @@ -407,191 +408,7 @@ export class DownloadsComponent { return; } - const source = await this.resolveSourceType(item.playlistId); - if (!source) { - return; - } - - if (source === 'xtream') { - await this.openXtreamItem(item); - return; - } - - await this.openStalkerItem(item); - } - - private getTargetContentId(item: DownloadItem): number | null { - const id = - item.contentType === 'episode' - ? (item.seriesXtreamId ?? item.xtreamId) - : item.xtreamId; - const numeric = Number(id); - return Number.isFinite(numeric) ? numeric : null; - } - - private buildPlaylistRoute( - source: PortalSource, - playlistId: string, - segments: Array - ): Array { - const sourceSegment = source === 'stalker' ? 'stalker' : 'xtreams'; - return ['/workspace', sourceSegment, playlistId, ...segments]; - } - - private async resolveSourceType( - playlistId: string - ): Promise { - try { - const playlist = await firstValueFrom( - this.playlistsService.getPlaylistById(playlistId) - ); - - if (!playlist) { - return null; - } - - if (playlist.portalUrl && playlist.macAddress) { - return 'stalker'; - } - - return 'xtream'; - } catch { - return null; - } - } - - private async openXtreamItem(item: DownloadItem): Promise { - const targetId = this.getTargetContentId(item); - if (targetId === null) return; - - const contentType = item.contentType === 'episode' ? 'series' : 'vod'; - const content = await this.dbService.getContentByXtreamId( - targetId, - item.playlistId - ); - const categoryId = content?.category_id; - - if (categoryId === null || categoryId === undefined) { - await this.router.navigate( - this.buildPlaylistRoute('xtream', item.playlistId, [ - contentType, - ]) - ); - return; - } - - await this.router.navigate( - this.buildPlaylistRoute('xtream', item.playlistId, [ - contentType, - String(categoryId), - String(targetId), - ]) - ); - } - - private normalizePortalItemId(value: unknown): string { - const raw = String(value ?? '').trim(); - if (!raw) return ''; - return raw.includes(':') ? raw.split(':')[0] : raw; - } - - private normalizeStalkerCategoryId( - value: unknown, - fallback: 'vod' | 'series' - ): 'vod' | 'series' | 'itv' { - const normalized = String(value ?? '').toLowerCase(); - if (normalized === 'movie') return 'vod'; - if ( - normalized === 'vod' || - normalized === 'series' || - normalized === 'itv' - ) { - return normalized; - } - return fallback; - } - - private findMatchingStalkerRecentItem( - items: Array>, - targetId: number - ): Record | undefined { - const expectedId = String(targetId); - return items.find((recentItem) => { - const candidates = [ - recentItem['id'], - recentItem['movie_id'], - recentItem['series_id'], - recentItem['stream_id'], - ]; - - return candidates.some( - (candidate) => - this.normalizePortalItemId(candidate) === expectedId - ); - }); - } - - private async buildStalkerOpenState( - item: DownloadItem, - targetId: number, - fallbackCategory: 'vod' | 'series' - ): Promise> { - try { - const items = (await firstValueFrom( - this.playlistsService.getPortalRecentlyViewed(item.playlistId) - )) as Array>; - - const matched = this.findMatchingStalkerRecentItem(items, targetId); - - if (matched) { - return { - ...matched, - id: - matched['id'] ?? - matched['series_id'] ?? - matched['movie_id'] ?? - String(targetId), - category_id: this.normalizeStalkerCategoryId( - matched['category_id'], - fallbackCategory - ), - title: matched['title'] ?? item.title, - name: matched['name'] ?? matched['o_name'] ?? item.title, - }; - } - } catch { - // Ignore and use fallback state below. - } - - return { - id: String(targetId), - category_id: fallbackCategory, - title: item.title, - name: item.title, - o_name: item.title, - cover: item.posterUrl, - logo: item.posterUrl, - }; - } - - private async openStalkerItem(item: DownloadItem): Promise { - const targetId = this.getTargetContentId(item); - if (targetId === null) return; - - const fallbackCategory = - item.contentType === 'episode' ? 'series' : 'vod'; - const openRecentItem = await this.buildStalkerOpenState( - item, - targetId, - fallbackCategory - ); - - await this.router.navigate( - this.buildPlaylistRoute('stalker', item.playlistId, ['recent']), - { - state: { openRecentItem }, - } - ); + await this.libraryNavigation.open(item); } private getPosterKey(item: DownloadItem): string { diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index 6082a75a7..937fe4d58 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -32,7 +32,6 @@ export const maxLinesBaseline = [ 'libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts', 'libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts', 'libs/playlist/shared/util/src/lib/playlist-context.facade.ts', - 'libs/portal/downloads/feature/src/lib/downloads.component.ts', 'libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts', 'libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts', 'libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts',