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),