From 19e25f1e4c3b80df50def3be7038d469bb28d9da Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 18:09:36 +0200 Subject: [PATCH] fix(settings): hide cached title matches the parental lock withholds Cross-playlist title matches are cached by their consumers (Actor and Discover routes, the dashboard trending and recommendation rails and the four "similar in your portals" rails), so matches found while unlocked kept advertising locked titles and their playlist names after a relock. Each consumer now filters on read through a reactive predicate on the match's provider category, so a relock hides them at once and an unlock shows them again, without a re-query. Closes #1723 Co-Authored-By: Claude Opus 5.5 --- docs/architecture/parental-lock.md | 10 +++- .../src/lib/stalker-actor-route.component.ts | 5 +- .../lib/stalker-discover-route.component.ts | 5 +- .../stalker-series-view.component.ts | 12 +++-- .../lib/actor/xtream-actor-route.component.ts | 5 +- .../xtream-discover-route.component.spec.ts | 43 +++++++++++++++++ .../xtream-discover-route.component.ts | 5 +- .../serial-details-similar.service.ts | 9 ++-- .../vod-details-route.actions.spec.ts | 1 + .../vod-details-similar.service.ts | 9 ++-- .../lib/catalog-title-match.service.spec.ts | 35 +++++++++++++- .../src/lib/catalog-title-match.service.ts | 20 ++++++++ .../lib/cross-portal-similar.service.spec.ts | 23 +++++++++ .../src/lib/cross-portal-similar.service.ts | 9 ++++ .../vod-details/vod-details.component.spec.ts | 1 + .../lib/vod-details/vod-details.component.ts | 12 +++-- .../dashboard-recommendations.service.spec.ts | 41 ++++++++++++---- .../lib/dashboard-recommendations.service.ts | 47 +++++++++++++------ .../src/lib/dashboard-source-expiry.spec.ts | 3 +- .../lib/dashboard-tmdb-lookup.util.spec.ts | 4 +- .../lib/dashboard-trending.service.spec.ts | 23 +++++++++ .../src/lib/dashboard-trending.service.ts | 18 +++++-- 22 files changed, 289 insertions(+), 51 deletions(-) diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 331645296..b5966b302 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -150,8 +150,14 @@ nothing while `withholdsEverything` is true, in either case. The VOD multi-source host keys its discovery session to the lock version: a lock change drops the discovered sources, retires discoveries and switches in flight, and rediscovers through the worker's new lock state. Title-match -results cached by the Actor/Discover routes and dashboard services are not -yet retired on relock (tracked in #1723). +results are cached by their consumers (Xtream/Stalker Actor and Discover +routes, the dashboard trending and recommendation rails, the four +"similar in your portals" rails), so each filters them on READ through +`CatalogTitleMatchService.visibleMatches` / `isWithheld` or +`CrossPortalSimilarService.visible`. Every match carries its provider +category id, and the predicate reads the lock state, so a relock hides +matches cached while unlocked (and those of a lookup issued before it) at +once, with no re-query; an unlock shows them again. The window before the initial read settles is treated the same way (`ParentalLockLockStore.readable` is false until then): settings can report the feature as on before the locks are known — and the workspace route's diff --git a/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts index 79fcdca71..c3c2b69a2 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts @@ -73,8 +73,11 @@ export class StalkerActorRouteComponent { readonly isMatchingGlobal = signal(false); private readonly globalMatches = signal(null); private readonly matchRequest = createLatestRequestGuard(); + // Filtered on read: a relock hides matches cached while unlocked. private readonly globalIndex = computed(() => - groupTitleMatchesByKey(this.globalMatches() ?? []) + groupTitleMatchesByKey( + this.titleMatch.visibleMatches(this.globalMatches() ?? []) + ) ); readonly items = computed(() => { diff --git a/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts index 049420343..b1af8a07f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts @@ -79,8 +79,11 @@ export class StalkerDiscoverRouteComponent { private readonly globalMatches = signal(null); private readonly matchRequest = createLatestRequestGuard(); private readonly discoverRequest = createLatestRequestGuard(); + // Filtered on read: a relock hides matches cached while unlocked. private readonly globalIndex = computed(() => - groupTitleMatchesByKey(this.globalMatches() ?? []) + groupTitleMatchesByKey( + this.titleMatch.visibleMatches(this.globalMatches() ?? []) + ) ); readonly items = computed(() => { diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 417b8f293..fb26d5a24 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -283,11 +283,17 @@ export class StalkerSeriesViewComponent implements OnDestroy { * match, Electron only) — Stalker catalogs are server-paginated, so * "Similar" can only point at OTHER portals' libraries. */ - readonly similarInPortals = signal([]); + private readonly similarInPortalsMatched = signal( + [] + ); + /** Filtered on read: a relock hides matches cached while unlocked. */ + readonly similarInPortals = computed(() => + this.crossPortalSimilar.visible(this.similarInPortalsMatched()) + ); private readonly loadSimilarInPortals = effect(() => { const recommendations = this.displayItem()?.info?.tmdb_recommendations; untracked(() => { - this.similarInPortals.set([]); + this.similarInPortalsMatched.set([]); if ( !recommendations?.length || !this.crossPortalSimilar.isAvailable @@ -301,7 +307,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.displayItem()?.info?.tmdb_recommendations === recommendations ) { - this.similarInPortals.set(items); + this.similarInPortalsMatched.set(items); } }); }); diff --git a/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts index 42e58f7f8..2d448734c 100644 --- a/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts @@ -87,8 +87,11 @@ export class XtreamActorRouteComponent { private readonly serialIndex = computed(() => buildCatalogTitleIndex(this.xtreamStore.serialStreams()) ); + // Filtered on read: a relock hides matches cached while unlocked. private readonly globalIndex = computed(() => - groupTitleMatchesByKey(this.globalMatches() ?? []) + groupTitleMatchesByKey( + this.titleMatch.visibleMatches(this.globalMatches() ?? []) + ) ); readonly items = computed(() => { diff --git a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts index b4c2090a0..6fcced29c 100644 --- a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts @@ -135,6 +135,49 @@ describe('XtreamDiscoverRouteComponent — catalog readiness', () => { expect(component.items()).toHaveLength(1); }); + it('stops marking a title available in another portal once the lock withholds it', async () => { + const withheld = signal(false); + const goodfellas = { + queryTitle: 'Goodfellas', + playlistId: 'pl-2', + playlistName: 'Other Portal', + categoryId: 9, + xtreamId: 7, + type: 'movie' as const, + trailingYear: null, + }; + TestBed.overrideProvider(CatalogTitleMatchService, { + useValue: { + isAvailable: true, + matchTitles: jest.fn().mockResolvedValue([goodfellas]), + visibleMatches: (matches: unknown[]) => + withheld() ? [] : matches, + }, + }); + const component = createComponent(); + resolveDiscover([ + { + tmdbId: 1, + mediaType: 'movie', + title: 'Goodfellas', + originalTitle: null, + year: 1990, + posterUrl: null, + }, + ]); + isLoadingContent.set(false); + isLoadingCategories.set(false); + await settle(); + component.onScopeChanged('global'); + await settle(); + expect(component.items()[0].available).toBe(true); + + // Lock now: the cached match must not keep the title available. + withheld.set(true); + expect(component.items()[0].available).toBe(false); + expect(component.items()[0].availableIn).toBeUndefined(); + }); + it('settles when the catalog load fails instead of spinning forever', async () => { const component = createComponent(); resolveDiscover(null); diff --git a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts index 6b6f26c5d..0399dd2bf 100644 --- a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts @@ -113,8 +113,11 @@ export class XtreamDiscoverRouteComponent { private readonly serialIndex = computed(() => buildCatalogTitleIndex(this.xtreamStore.serialStreams()) ); + // Filtered on read: a relock hides matches cached while unlocked. private readonly globalIndex = computed(() => - groupTitleMatchesByKey(this.globalMatches() ?? []) + groupTitleMatchesByKey( + this.titleMatch.visibleMatches(this.globalMatches() ?? []) + ) ); readonly items = computed(() => { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-similar.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-similar.service.ts index eacfe5799..d8dd2bfc4 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-similar.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-similar.service.ts @@ -64,9 +64,12 @@ export class SerialDetailsSimilarService { (item) => normalizeTitleKeys(item.title).exact ) ); - return this.crossPortalItems().filter( - (item) => !localTitles.has(normalizeTitleKeys(item.title).exact) - ); + // Filtered on read: a relock hides matches cached while unlocked. + return this.crossPortalSimilar + .visible(this.crossPortalItems()) + .filter( + (item) => !localTitles.has(normalizeTitleKeys(item.title).exact) + ); }); private readonly loadCrossPortalSimilar = effect(() => { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts index 019500ff6..eac9b0387 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts @@ -312,6 +312,7 @@ describe('VodDetailsRouteComponent fallback actions', () => { isAvailable: false, buildLink: jest.fn(), matchRecommendations: jest.fn(), + visible: (items: T[]) => items, }, }, { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-similar.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-similar.service.ts index b05d62baa..0904582bb 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-similar.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-similar.service.ts @@ -84,9 +84,12 @@ export class VodDetailsSimilarService { ) ); - return this.crossPortalItems().filter( - (item) => !localTitles.has(normalizeTitleKeys(item.title).exact) - ); + // Filtered on read: a relock hides matches cached while unlocked. + return this.crossPortalSimilar + .visible(this.crossPortalItems()) + .filter( + (item) => !localTitles.has(normalizeTitleKeys(item.title).exact) + ); }); private readonly loadCrossPortalSimilar = effect(() => { diff --git a/libs/services/src/lib/catalog-title-match.service.spec.ts b/libs/services/src/lib/catalog-title-match.service.spec.ts index 4ae8a18c9..b4f24bd68 100644 --- a/libs/services/src/lib/catalog-title-match.service.spec.ts +++ b/libs/services/src/lib/catalog-title-match.service.spec.ts @@ -1,4 +1,4 @@ -import { signal } from '@angular/core'; +import { computed, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; import { @@ -219,4 +219,37 @@ describe('CatalogTitleMatchService parental lock', () => { expect(dbMatchTitles).toHaveBeenCalledTimes(withholds ? 0 : 1); } ); + + it('filters cached matches by the lock state at read time', () => { + const locked = signal(new Set()); + const isXtreamCategoryLocked = jest.fn( + (playlistId: string, type: string, categoryId: number) => + locked().has(`${playlistId}:${type}:${categoryId}`) + ); + TestBed.configureTestingModule({ + providers: [ + { + provide: ParentalLockService, + useValue: { isXtreamCategoryLocked }, + }, + ], + }); + const service = TestBed.inject(CatalogTitleMatchService); + const film = { + queryTitle: 'Dune', + playlistId: 'pl-1', + playlistName: 'Portal', + categoryId: 7, + xtreamId: 1, + type: 'movie' as const, + trailingYear: null, + }; + const show = { ...film, type: 'series' as const, xtreamId: 2 }; + const visible = computed(() => service.visibleMatches([film, show])); + expect(visible()).toEqual([film, show]); + + // The film's category is locked: its category type is 'movies'. + locked.set(new Set(['pl-1:movies:7'])); + expect(visible()).toEqual([show]); + }); }); diff --git a/libs/services/src/lib/catalog-title-match.service.ts b/libs/services/src/lib/catalog-title-match.service.ts index e15d090d9..9b9c23135 100644 --- a/libs/services/src/lib/catalog-title-match.service.ts +++ b/libs/services/src/lib/catalog-title-match.service.ts @@ -128,4 +128,24 @@ export class CatalogTitleMatchService { return []; } } + + /** + * Whether the parental lock withholds this match's category now. It + * reads the lock state, so a `computed` that filters with it re-runs on + * every relock and unlock: matches cached while unlocked (or returned + * by a lookup issued before a relock) must not keep advertising a + * locked title or its playlist name. + */ + isWithheld(match: CatalogTitleMatch): boolean { + return this.parentalLock.isXtreamCategoryLocked( + match.playlistId, + match.type === 'movie' ? 'movies' : 'series', + match.categoryId + ); + } + + /** `matches` without the ones the lock withholds; reactive, see above. */ + visibleMatches(matches: readonly CatalogTitleMatch[]): CatalogTitleMatch[] { + return matches.filter((match) => !this.isWithheld(match)); + } } diff --git a/libs/services/src/lib/cross-portal-similar.service.spec.ts b/libs/services/src/lib/cross-portal-similar.service.spec.ts index a3d2db210..6a69ceabc 100644 --- a/libs/services/src/lib/cross-portal-similar.service.spec.ts +++ b/libs/services/src/lib/cross-portal-similar.service.spec.ts @@ -26,6 +26,7 @@ describe('CrossPortalSimilarService', () => { let matchTitles: jest.Mock; let isAvailable: boolean; + let withheld: Set; // The services Jest target has no @angular/core/testing — construct // the service in a plain injection context instead of TestBed @@ -39,6 +40,8 @@ describe('CrossPortalSimilarService', () => { return isAvailable; }, matchTitles, + isWithheld: (m: CatalogTitleMatch) => + withheld.has(m.xtreamId), }, }, ], @@ -51,6 +54,7 @@ describe('CrossPortalSimilarService', () => { beforeEach(() => { isAvailable = true; + withheld = new Set(); matchTitles = jest.fn().mockResolvedValue([match()]); }); @@ -128,4 +132,23 @@ describe('CrossPortalSimilarService', () => { expect(items).toEqual([]); }); + + it('drops items whose match the lock withholds', () => { + const service = createService(); + const kept = { + title: 'A', + posterUrl: null, + year: null, + match: match(), + }; + const locked = { + title: 'B', + posterUrl: null, + year: null, + match: match({ xtreamId: 99 }), + }; + withheld.add(99); + + expect(service.visible([kept, locked])).toEqual([kept]); + }); }); diff --git a/libs/services/src/lib/cross-portal-similar.service.ts b/libs/services/src/lib/cross-portal-similar.service.ts index ca6068b56..85f523a35 100644 --- a/libs/services/src/lib/cross-portal-similar.service.ts +++ b/libs/services/src/lib/cross-portal-similar.service.ts @@ -90,6 +90,15 @@ export class CrossPortalSimilarService { return items; } + /** + * `items` without the ones whose match the parental lock withholds now. + * Reactive (see `CatalogTitleMatchService.isWithheld`): filter inside + * a `computed` so cached rails follow relock and unlock. + */ + visible(items: readonly T[]): T[] { + return items.filter((item) => !this.titleMatch.isWithheld(item.match)); + } + /** Route array for one match: the item's detail view in its portal */ buildLink(item: CrossPortalSimilarItem): string[] { return [ diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts index 0a1dc6b00..97560312a 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts @@ -271,6 +271,7 @@ describe('VodDetailsComponent offline playback', () => { isAvailable: false, matchRecommendations: jest.fn().mockResolvedValue([]), buildLink: jest.fn(), + visible: (items: T[]) => items, }, }, { diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index 5b804f61e..17ad45fb1 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -188,13 +188,19 @@ export class VodDetailsComponent { * match, Electron only). Loaded async — the section appears when * resolved; staleness-guarded against item changes in flight. */ - readonly similarInPortals = signal([]); + private readonly similarInPortalsMatched = signal( + [] + ); + /** Filtered on read: a relock hides matches cached while unlocked. */ + readonly similarInPortals = computed(() => + this.crossPortalSimilar.visible(this.similarInPortalsMatched()) + ); private readonly loadSimilarInPortals = effect(() => { const meta = this.normalizedMeta(); const recommendations = meta.tmdbRecommendations; untracked(() => { - this.similarInPortals.set([]); + this.similarInPortalsMatched.set([]); if ( !recommendations?.length || !this.crossPortalSimilar.isAvailable @@ -208,7 +214,7 @@ export class VodDetailsComponent { this.normalizedMeta().tmdbRecommendations === recommendations ) { - this.similarInPortals.set(items); + this.similarInPortalsMatched.set(items); } }); }); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts index e92a6798a..3427360d1 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts @@ -1,3 +1,4 @@ +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { CatalogTitleMatchService, @@ -15,6 +16,12 @@ interface ActivityStub { } describe('DashboardRecommendationsService', () => { + // The parental lock's view of each match's category, as a signal so a + // relock re-runs the services' computed filters. + const withheldCategories = signal(new Set()); + const isWithheld = (match: CatalogTitleMatch) => + withheldCategories().has(match.categoryId); + const rec = (id: number, title: string, year = 2010) => ({ id, title, @@ -86,7 +93,9 @@ describe('DashboardRecommendationsService', () => { enrichTv.mockResolvedValue({ recommendations: { results: [ - ...recTitles.slice(0, 5).map((title, i) => rec(100 + i, title)), + ...recTitles + .slice(0, 5) + .map((title, i) => rec(100 + i, title)), ...extraTitles.map((title, i) => rec(200 + i, title)), ], }, @@ -117,6 +126,7 @@ describe('DashboardRecommendationsService', () => { useValue: { isAvailable: options.matchingAvailable ?? true, matchTitles, + isWithheld, }, }, { @@ -134,6 +144,7 @@ describe('DashboardRecommendationsService', () => { } beforeEach(() => { + withheldCategories.set(new Set()); isEnabled = jest.fn().mockReturnValue(true); enrichMovie = jest.fn().mockResolvedValue({ recommendations: { @@ -168,6 +179,22 @@ describe('DashboardRecommendationsService', () => { expect(enrichTv).not.toHaveBeenCalled(); }); + it('hides cards whose category the lock withholds, on read', async () => { + const service = createService(); + await service.load(); + expect(service.items()).toHaveLength(recTitles.length); + + // Lock now: every card sits in the locked category, so the rail + // (and its "Because you watched" seed) disappears. + withheldCategories.set(new Set([7])); + expect(service.items()).toEqual([]); + expect(service.seedTitles()).toEqual([]); + + withheldCategories.set(new Set()); + expect(service.items()).toHaveLength(recTitles.length); + expect(service.seedTitles()).toEqual(['The Matrix']); + }); + it('does nothing when TMDB is disabled', async () => { isEnabled.mockReturnValue(false); const service = createService(); @@ -372,9 +399,7 @@ describe('DashboardRecommendationsService', () => { await service.load(); - const hunt = service - .items() - .find((item) => item.title === 'The Hunt'); + const hunt = service.items().find((item) => item.title === 'The Hunt'); expect(hunt?.match.xtreamId).toBe(2012); }); @@ -644,9 +669,7 @@ describe('DashboardRecommendationsService', () => { await service.load(); - const hunt = service - .items() - .find((item) => item.title === 'The Hunt'); + const hunt = service.items().find((item) => item.title === 'The Hunt'); expect(hunt).toBeDefined(); expect(hunt?.match.queryTitle).toBe('Jagten'); }); @@ -835,9 +858,7 @@ describe('DashboardRecommendationsService', () => { await service.load(); - expect(service.items().map((item) => item.title)).toContain( - 'Godzilla' - ); + expect(service.items().map((item) => item.title)).toContain('Godzilla'); }); it('excludes a watched title whose name ends in a year', async () => { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.ts index 52f122b9d..f219e6cee 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.ts @@ -1,4 +1,4 @@ -import { Injectable, inject, signal } from '@angular/core'; +import { computed, Injectable, inject, signal } from '@angular/core'; import { CatalogTitleMatchService, TmdbEnrichmentService, @@ -61,9 +61,26 @@ export class DashboardRecommendationsService { private readonly titleMatch = inject(CatalogTitleMatchService); private readonly data = inject(DashboardDataService); - readonly items = signal([]); + private readonly matchedItems = signal([]); + private readonly matchedSeedTitles = signal([]); + /** + * The matched cards the parental lock does not withhold, filtered on + * read so a relock hides cards matched while unlocked. Under the match + * threshold the rail hides, as it does on load. + */ + readonly items = computed(() => { + const visible = this.matchedItems().filter( + (item) => !this.titleMatch.isWithheld(item.match) + ); + return visible.length < MIN_RECOMMENDATION_MATCHES ? [] : visible; + }); /** Seeds that contributed at least one visible card, most recent first */ - readonly seedTitles = signal([]); + readonly seedTitles = computed(() => { + const contributed = new Set(this.items().map((item) => item.seedTitle)); + return this.matchedSeedTitles().filter((title) => + contributed.has(title) + ); + }); readonly loading = signal(false); private loadedKey: string | null = null; @@ -87,8 +104,8 @@ export class DashboardRecommendationsService { if (seeds.length === 0) { // The service outlives the dashboard (root-provided), so a // cleared watch history must clear the rail too. - this.items.set([]); - this.seedTitles.set([]); + this.matchedItems.set([]); + this.matchedSeedTitles.set([]); this.loadedKey = null; return; } @@ -130,8 +147,8 @@ export class DashboardRecommendationsService { const contributed = new Set( matched.map((item) => item.seedTitle) ); - this.items.set(matched); - this.seedTitles.set( + this.matchedItems.set(matched); + this.matchedSeedTitles.set( perSeed .map((seed) => seed.seedTitle) .filter((title) => contributed.has(title)) @@ -158,8 +175,8 @@ export class DashboardRecommendationsService { // rail that was just cleared, and returning to those // exact inputs (say, un-favoriting again) would // otherwise hit the equality guard and stay empty. - this.items.set([]); - this.seedTitles.set([]); + this.matchedItems.set([]); + this.matchedSeedTitles.set([]); this.loadedKey = null; } } @@ -183,7 +200,7 @@ export class DashboardRecommendationsService { * under the match threshold hides the rail, as everywhere else. */ private dropExcludedCards(excluded: ExclusionIndex): void { - const current = this.items(); + const current = this.matchedItems(); // A card whose playlist is gone would navigate to a dead route, // and the failed refresh is no excuse for keeping it — this is // the only path that can reach a deleted playlist without the @@ -207,15 +224,15 @@ export class DashboardRecommendationsService { this.loadedKey = null; if (kept.length < MIN_RECOMMENDATION_MATCHES) { - this.items.set([]); - this.seedTitles.set([]); + this.matchedItems.set([]); + this.matchedSeedTitles.set([]); return; } const contributed = new Set(kept.map((item) => item.seedTitle)); - this.items.set(kept); - this.seedTitles.set( - this.seedTitles().filter((title) => contributed.has(title)) + this.matchedItems.set(kept); + this.matchedSeedTitles.set( + this.matchedSeedTitles().filter((title) => contributed.has(title)) ); } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.spec.ts index cd9184dfd..cd9f80fb7 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.spec.ts @@ -140,7 +140,8 @@ describe('DashboardSourceExpiryService', () => { }); it('collects Xtream facts from the shared status details', async () => { - const expiresAtSeconds = Math.floor(Date.now() / 1000) + 3 * DAY_SECONDS; + const expiresAtSeconds = + Math.floor(Date.now() / 1000) + 3 * DAY_SECONDS; portalStatusService.checkPortalStatusDetails.mockResolvedValue({ status: 'active', expiresAtSeconds, diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-tmdb-lookup.util.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-tmdb-lookup.util.spec.ts index 14fdd049a..ddfdec731 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-tmdb-lookup.util.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-tmdb-lookup.util.spec.ts @@ -87,9 +87,7 @@ describe('buildDashboardTmdbAttempts', () => { ['an implausible year', { release_year: 12 }], ['a blank original title', { original_title: ' ' }], ])('ignores %s', (_label, overrides) => { - const [attempt] = buildDashboardTmdbAttempts( - xtreamItem(overrides) - ); + const [attempt] = buildDashboardTmdbAttempts(xtreamItem(overrides)); expect(attempt.tmdbId).toBeUndefined(); expect(attempt.year).toBeNull(); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.spec.ts index c55447a92..fd3fe96cb 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.spec.ts @@ -1,3 +1,4 @@ +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { CatalogTitleMatchService, @@ -8,6 +9,12 @@ import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; import { DashboardTrendingService } from './dashboard-trending.service'; describe('DashboardTrendingService', () => { + // The parental lock's view of each match's category, as a signal so a + // relock re-runs the services' computed filters. + const withheldCategories = signal(new Set()); + const isWithheld = (match: CatalogTitleMatch) => + withheldCategories().has(match.categoryId); + const entry = ( overrides: Partial = {} ): TmdbTrendingEntry => ({ @@ -52,6 +59,7 @@ describe('DashboardTrendingService', () => { useValue: { isAvailable: options.matchingAvailable ?? true, matchTitles, + isWithheld, }, }, ], @@ -60,6 +68,7 @@ describe('DashboardTrendingService', () => { } beforeEach(() => { + withheldCategories.set(new Set()); isEnabled = jest.fn().mockReturnValue(true); getTrendingWeek = jest.fn().mockResolvedValue([entry()]); matchTitles = jest.fn().mockResolvedValue([match()]); @@ -93,6 +102,20 @@ describe('DashboardTrendingService', () => { expect(service.loading()).toBe(false); }); + it('drops the library match of a category the lock withholds, on read', async () => { + const service = createService(); + await service.load(); + expect(service.items()[0].match).not.toBeNull(); + + // Lock now: the cached match must stop advertising the title. + withheldCategories.set(new Set([7])); + expect(service.items()).toHaveLength(1); + expect(service.items()[0].match).toBeNull(); + + withheldCategories.set(new Set()); + expect(service.items()[0].match?.playlistName).toBe('My Portal'); + }); + it('rejects year-incompatible base-tier matches', async () => { getTrendingWeek.mockResolvedValue([ entry({ title: 'Blade Runner', year: 1982 }), diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.ts index 11824e065..43011f31b 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.ts @@ -1,4 +1,4 @@ -import { Injectable, inject, signal } from '@angular/core'; +import { computed, Injectable, inject, signal } from '@angular/core'; import { CatalogTitleMatchService, TmdbEnrichmentService, @@ -26,7 +26,19 @@ export class DashboardTrendingService { private readonly enrichment = inject(TmdbEnrichmentService); private readonly titleMatch = inject(CatalogTitleMatchService); - readonly items = signal([]); + private readonly matchedItems = signal([]); + /** + * Trending entries with their library match, dropped (the entry stays, + * unavailable) while the parental lock withholds its category. Filtered + * on read, so a relock hides matches found while unlocked. + */ + readonly items = computed(() => + this.matchedItems().map((item) => + item.match && this.titleMatch.isWithheld(item.match) + ? { ...item, match: null } + : item + ) + ); readonly loading = signal(false); private loadedOnce = false; @@ -57,7 +69,7 @@ export class DashboardTrendingService { ); const grouped = groupTitleMatchesByKey(matches); - this.items.set( + this.matchedItems.set( entries.map((entry) => ({ ...entry, match: this.matchFor(entry, grouped),