diff --git a/.changes/settings-parental-lock-title-matches.md b/.changes/settings-parental-lock-title-matches.md new file mode 100644 index 000000000..35c3fbed8 --- /dev/null +++ b/.changes/settings-parental-lock-title-matches.md @@ -0,0 +1,7 @@ +--- +type: fix +area: settings +issues: [1723] +--- + +Locking the app now also hides locked titles from "available in your library" hints on actor, discover and dashboard pages and from the "similar in your portals" rails. A title that also exists in an unlocked playlist stays available through that copy. diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 331645296..d049b35b0 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -150,8 +150,19 @@ 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. Consumers keep ALL the +rows a lookup returned and pick a title's match from the visible ones on +read (the Actor/Discover indexes, the trending rail, the recommendation +rail through `buildRecommendationItems`, and each "similar" item's +`candidates`), so a title that also exists in an unlocked portal stays +available through that copy instead of disappearing with the locked one. 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..9883b7d9b 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,10 @@ describe('CrossPortalSimilarService', () => { return isAvailable; }, matchTitles, + isWithheld: (m: CatalogTitleMatch) => + withheld.has(m.xtreamId), + visibleMatches: (ms: CatalogTitleMatch[]) => + ms.filter((m) => !withheld.has(m.xtreamId)), }, }, ], @@ -51,6 +56,7 @@ describe('CrossPortalSimilarService', () => { beforeEach(() => { isAvailable = true; + withheld = new Set(); matchTitles = jest.fn().mockResolvedValue([match()]); }); @@ -128,4 +134,47 @@ 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]); + }); + + it('falls back to an unlocked candidate when the chosen match is withheld', async () => { + matchTitles.mockResolvedValue([ + match(), + match({ + playlistId: 'pl-2', + playlistName: 'Portal Two', + xtreamId: 43, + }), + ]); + const service = createService(); + const [item] = await service.matchRecommendations( + [rec('The Matrix')], + 'movie' + ); + expect(item.match.xtreamId).toBe(42); + + withheld.add(42); + const [visible] = service.visible([item]); + expect(visible.match.playlistName).toBe('Portal Two'); + + withheld.add(43); + expect(service.visible([item])).toEqual([]); + }); }); diff --git a/libs/services/src/lib/cross-portal-similar.service.ts b/libs/services/src/lib/cross-portal-similar.service.ts index ca6068b56..c2fe31d41 100644 --- a/libs/services/src/lib/cross-portal-similar.service.ts +++ b/libs/services/src/lib/cross-portal-similar.service.ts @@ -1,9 +1,11 @@ import { Injectable, inject } from '@angular/core'; import { CatalogTitleMatch, + normalizeTitleKeys, TmdbRecommendation, } from '@iptvnator/shared/interfaces'; import { + CatalogTitleLookup, CatalogTitleMatchService, groupTitleMatchesByKey, pickTitleMatch, @@ -16,6 +18,12 @@ export interface CrossPortalSimilarItem { year: number | null; /** Where to navigate: playlist + category + item in that portal */ match: CatalogTitleMatch; + /** + * Every eligible catalog row found for this title (the chosen `match` + * included), so `visible()` can fall back to a copy in an unlocked + * portal when the parental lock withholds the chosen one. + */ + candidates?: readonly CatalogTitleMatch[]; } const DEFAULT_LIMIT = 12; @@ -64,14 +72,12 @@ export class CrossPortalSimilarService { if (items.length >= limit) { break; } - const match = pickTitleMatch( - { - type, - titles: [recommendation.title], - year: recommendation.year, - }, - grouped - ); + const lookup = { + type, + titles: [recommendation.title], + year: recommendation.year, + }; + const match = pickTitleMatch(lookup, grouped); if (!match) { continue; } @@ -85,11 +91,47 @@ export class CrossPortalSimilarService { posterUrl: recommendation.posterUrl, year: recommendation.year, match, + candidates: titleMatchCandidates(lookup, grouped), }); } return items; } + /** + * `items` as the parental lock allows them now. An item whose chosen + * match is withheld falls back to its best unlocked candidate, and is + * dropped only when none is left (or another item already shows that + * row). Reactive (see `CatalogTitleMatchService.isWithheld`): call it + * inside a `computed` so cached rails follow relock and unlock. + */ + visible(items: readonly T[]): T[] { + const seen = new Set(); + const visible: T[] = []; + for (const item of items) { + const match = this.titleMatch.isWithheld(item.match) + ? pickTitleMatch( + { + type: item.match.type, + titles: [item.title], + year: item.year, + }, + groupTitleMatchesByKey( + this.titleMatch.visibleMatches(item.candidates ?? []) + ) + ) + : item.match; + const key = match + ? `${match.playlistId}:${match.type}:${match.xtreamId}` + : null; + if (!match || !key || seen.has(key)) { + continue; + } + seen.add(key); + visible.push(match === item.match ? item : { ...item, match }); + } + return visible; + } + /** Route array for one match: the item's detail view in its portal */ buildLink(item: CrossPortalSimilarItem): string[] { return [ @@ -101,3 +143,16 @@ export class CrossPortalSimilarService { ]; } } + +/** The grouped rows a lookup's titles resolve to, in grouping order. */ +function titleMatchCandidates( + lookup: CatalogTitleLookup, + grouped: ReadonlyMap +): CatalogTitleMatch[] { + const candidates: CatalogTitleMatch[] = []; + for (const title of lookup.titles) { + const key = `${lookup.type}:${normalizeTitleKeys(title).exact}`; + candidates.push(...(grouped.get(key) ?? [])); + } + return candidates; +} 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..10602e866 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,9 @@ describe('DashboardRecommendationsService', () => { useValue: { isAvailable: options.matchingAvailable ?? true, matchTitles, + isWithheld, + visibleMatches: (matches: CatalogTitleMatch[]) => + matches.filter((m) => !isWithheld(m)), }, }, { @@ -134,6 +146,7 @@ describe('DashboardRecommendationsService', () => { } beforeEach(() => { + withheldCategories.set(new Set()); isEnabled = jest.fn().mockReturnValue(true); enrichMovie = jest.fn().mockResolvedValue({ recommendations: { @@ -168,6 +181,44 @@ 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('keeps cards available through an unlocked copy when the chosen one is withheld', async () => { + matchTitles.mockImplementation(async (titles: string[]) => + titles.flatMap((title) => [ + match(title), + match(title, { + playlistId: 'pl-2', + playlistName: 'Other', + categoryId: 8, + }), + ]) + ); + const service = createService(); + await service.load(); + expect(service.items()[0].match.playlistName).toBe('My Portal'); + + withheldCategories.set(new Set([7])); + expect(service.items()).toHaveLength(recTitles.length); + expect( + service.items().every((item) => item.match.playlistName === 'Other') + ).toBe(true); + }); + it('does nothing when TMDB is disabled', async () => { isEnabled.mockReturnValue(false); const service = createService(); @@ -372,9 +423,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 +693,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 +882,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 () => { @@ -999,4 +1044,36 @@ describe('DashboardRecommendationsService', () => { expect(service.seedTitles()).toEqual(['The Matrix']); }); + + it('derives the seed heading from the cards visible now', async () => { + recentVod = [ + { title: 'The Matrix', type: 'movie' }, + { title: 'Blade Runner', type: 'movie' }, + ]; + recentAll = [...recentVod]; + const second = ['Akira', 'Ghost in the Shell', 'Paprika']; + enrichMovie.mockImplementation(async (query: { title: string }) => ({ + recommendations: { + results: + query.title === 'The Matrix' + ? recTitles.map((t, i) => rec(100 + i, t)) + : second.map((t, i) => rec(300 + i, t)), + }, + })); + // The second seed's recommendations sit in category 8. + matchTitles.mockImplementation(async (titles: string[]) => + titles.map((title) => + match(title, second.includes(title) ? { categoryId: 8 } : {}) + ) + ); + // Locked at load: the second seed has no visible card yet. + withheldCategories.set(new Set([8])); + const service = createService(); + await service.load(); + expect(service.seedTitles()).toEqual(['The Matrix']); + + // Unlocked: its cards appear, and so does its heading. + withheldCategories.set(new Set()); + expect(service.seedTitles()).toEqual(['The Matrix', 'Blade Runner']); + }); }); 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..46c87951c 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,11 +1,12 @@ -import { Injectable, inject, signal } from '@angular/core'; +import { computed, Injectable, inject, signal } from '@angular/core'; import { CatalogTitleMatchService, TmdbEnrichmentService, - groupTitleMatchesByKey, - pickTitleMatch, } from '@iptvnator/services'; -import { normalizeTitleKeys } from '@iptvnator/shared/interfaces'; +import { + CatalogTitleMatch, + normalizeTitleKeys, +} from '@iptvnator/shared/interfaces'; import { DashboardDataService } from './dashboard-data.service'; import { DashboardTmdbLookupItem, @@ -13,16 +14,22 @@ import { dashboardTmdbLookupKey, } from './dashboard-tmdb-lookup.util'; import { - DashboardRecommendationItem, ExclusionIndex, RecommendationCandidate, buildLoadKey, - candidateLookup, + buildRecommendationItems, isExcludedCandidate, toCandidates, trustedReleaseYear, } from './dashboard-recommendations.util'; +interface LoadedRecommendations { + candidates: readonly RecommendationCandidate[]; + matches: readonly CatalogTitleMatch[]; +} + +const EMPTY_LOAD: LoadedRecommendations = { candidates: [], matches: [] }; + interface SeedRecommendations { resolved: boolean; seedTitle: string; @@ -61,9 +68,34 @@ export class DashboardRecommendationsService { private readonly titleMatch = inject(CatalogTitleMatchService); private readonly data = inject(DashboardDataService); - readonly items = signal([]); + /** The last load's candidates and ALL their catalog matches. */ + private readonly loaded = signal(EMPTY_LOAD); + /** + * Every seed of the last load, most recent first. `seedTitles` keeps + * those that contribute a card NOW: a relock can hide some cards and let + * another seed's recommendations fill their places. + */ + private readonly seedOrder = signal([]); + /** + * The cards, built on READ from the matches the parental lock does not + * withhold: a relock hides a card matched while unlocked, and a title + * that also exists in an unlocked portal stays on the rail through that + * copy. Under the match threshold the rail hides, as it does on load. + */ + readonly items = computed(() => { + const { candidates, matches } = this.loaded(); + const visible = buildRecommendationItems( + candidates, + this.titleMatch.visibleMatches(matches), + MAX_ITEMS + ); + 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.seedOrder().filter((title) => contributed.has(title)); + }); readonly loading = signal(false); private loadedKey: string | null = null; @@ -87,8 +119,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.loaded.set(EMPTY_LOAD); + this.seedOrder.set([]); this.loadedKey = null; return; } @@ -125,17 +157,15 @@ export class DashboardRecommendationsService { perSeed.map((seed) => seed.entries), excluded ); - const matched = await this.attachMatches(candidates); + const load = await this.attachMatches(candidates); + const matched = buildRecommendationItems( + load.candidates, + load.matches, + MAX_ITEMS + ); if (matched.length >= MIN_RECOMMENDATION_MATCHES) { - const contributed = new Set( - matched.map((item) => item.seedTitle) - ); - this.items.set(matched); - this.seedTitles.set( - perSeed - .map((seed) => seed.seedTitle) - .filter((title) => contributed.has(title)) - ); + this.loaded.set(load); + this.seedOrder.set(perSeed.map((seed) => seed.seedTitle)); // Latch only once EVERY seed answered. A seed that did // not resolve may have failed transiently, and latching // on its behalf would drop its recommendations for the @@ -158,8 +188,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.loaded.set(EMPTY_LOAD); + this.seedOrder.set([]); this.loadedKey = null; } } @@ -183,7 +213,7 @@ export class DashboardRecommendationsService { * under the match threshold hides the rail, as everywhere else. */ private dropExcludedCards(excluded: ExclusionIndex): void { - const current = this.items(); + const { candidates, matches } = this.loaded(); // 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 @@ -191,12 +221,18 @@ export class DashboardRecommendationsService { const livePlaylists = new Set( this.data.playlists().map((playlist) => playlist._id) ); - const kept = current.filter( - (item) => - livePlaylists.has(item.match.playlistId) && - !isExcludedCandidate(item, excluded) - ); - if (kept.length === current.length) { + const kept: LoadedRecommendations = { + candidates: candidates.filter( + (candidate) => !isExcludedCandidate(candidate, excluded) + ), + matches: matches.filter((match) => + livePlaylists.has(match.playlistId) + ), + }; + if ( + kept.candidates.length === candidates.length && + kept.matches.length === matches.length + ) { return; } @@ -206,17 +242,18 @@ export class DashboardRecommendationsService { // would hit the equality guard and leave the rail as it is now. this.loadedKey = null; - if (kept.length < MIN_RECOMMENDATION_MATCHES) { - this.items.set([]); - this.seedTitles.set([]); + const cards = buildRecommendationItems( + kept.candidates, + kept.matches, + MAX_ITEMS + ); + if (cards.length < MIN_RECOMMENDATION_MATCHES) { + this.loaded.set(EMPTY_LOAD); + this.seedOrder.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.loaded.set(kept); } /** @@ -408,12 +445,12 @@ export class DashboardRecommendationsService { private async attachMatches( candidates: readonly RecommendationCandidate[] - ): Promise { + ): Promise { if (candidates.length === 0) { - return []; + return EMPTY_LOAD; } // Both aliases go into the ONE batched request; the index lookup - // below prefers the localized form. Built with a loop rather than + // prefers the localized form. Built with a loop rather than // flatMap — the web app compiles this lib against `lib: es2018`, // which predates Array.prototype.flatMap. const queryTitles: string[] = []; @@ -424,29 +461,6 @@ export class DashboardRecommendationsService { } } const matches = await this.titleMatch.matchTitles(queryTitles); - const grouped = groupTitleMatchesByKey(matches); - - // Title collisions are resolved HERE rather than before matching: - // two candidates that resolve to the same catalog row would render - // as duplicate cards opening the same item, while same-titled - // remakes resolve to different rows and both belong on the rail. - const items: DashboardRecommendationItem[] = []; - const claimedRows = new Set(); - for (const candidate of candidates) { - const match = pickTitleMatch(candidateLookup(candidate), grouped); - if (!match) { - continue; - } - const rowKey = `${match.playlistId}:${match.type}:${match.xtreamId}`; - if (claimedRows.has(rowKey)) { - continue; - } - claimedRows.add(rowKey); - items.push({ ...candidate, match }); - if (items.length === MAX_ITEMS) { - break; - } - } - return items; + return { candidates, matches }; } } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts index 55eea98b7..a232249de 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts @@ -1,4 +1,9 @@ -import { extractYear, tmdbPosterUrl } from '@iptvnator/services'; +import { + extractYear, + groupTitleMatchesByKey, + pickTitleMatch, + tmdbPosterUrl, +} from '@iptvnator/services'; import type { CatalogTitleLookup, TmdbSearchResult } from '@iptvnator/services'; import { CatalogTitleMatch, @@ -223,3 +228,38 @@ export function toCandidates( }) .filter((entry) => entry.tmdbId > 0 && entry.title !== ''); } + +/** + * The rail's cards: each candidate with the catalog row it resolves to. + * Title collisions are resolved HERE rather than before matching: two + * candidates that resolve to the same catalog row would render as + * duplicate cards opening the same item, while same-titled remakes + * resolve to different rows and both belong on the rail. Pure, so the + * service can rebuild the rail from the matches the parental lock does + * not withhold whenever the lock changes. + */ +export function buildRecommendationItems( + candidates: readonly RecommendationCandidate[], + matches: readonly CatalogTitleMatch[], + limit: number +): DashboardRecommendationItem[] { + const grouped = groupTitleMatchesByKey(matches); + const items: DashboardRecommendationItem[] = []; + const claimedRows = new Set(); + for (const candidate of candidates) { + const match = pickTitleMatch(candidateLookup(candidate), grouped); + if (!match) { + continue; + } + const rowKey = `${match.playlistId}:${match.type}:${match.xtreamId}`; + if (claimedRows.has(rowKey)) { + continue; + } + claimedRows.add(rowKey); + items.push({ ...candidate, match }); + if (items.length === limit) { + break; + } + } + return items; +} 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-source-expiry.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts index 0e1627099..20a3e2969 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts @@ -23,8 +23,7 @@ export interface SourceExpiryFacts { } export type SourceExpiryBadge = - | { kind: 'expired' } - | { kind: 'expiring'; daysLeft: number }; + { kind: 'expired' } | { kind: 'expiring'; daysLeft: number }; /** * Decides whether a source card should carry an expiry badge. Returns null 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..c04e3716f 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,9 @@ describe('DashboardTrendingService', () => { useValue: { isAvailable: options.matchingAvailable ?? true, matchTitles, + isWithheld, + visibleMatches: (matches: CatalogTitleMatch[]) => + matches.filter((m) => !isWithheld(m)), }, }, ], @@ -60,6 +70,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 +104,33 @@ 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('falls back to a copy in an unlocked portal when the chosen match is withheld', async () => { + matchTitles.mockResolvedValue([ + match(), + match({ playlistId: 'pl-2', playlistName: 'Other', categoryId: 8 }), + ]); + const service = createService(); + await service.load(); + expect(service.items()[0].match?.playlistId).toBe('pl-1'); + + withheldCategories.set(new Set([7])); + expect(service.items()[0].match?.playlistName).toBe('Other'); + }); + 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..857eb9a87 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,26 @@ export class DashboardTrendingService { private readonly enrichment = inject(TmdbEnrichmentService); private readonly titleMatch = inject(CatalogTitleMatchService); - readonly items = signal([]); + private readonly loaded = signal<{ + entries: TmdbTrendingEntry[]; + matches: CatalogTitleMatch[]; + }>({ entries: [], matches: [] }); + /** + * Trending entries with their library match, picked on READ from the + * matches the parental lock does not withhold: a relock hides a match + * found while unlocked, and a title that also exists in an unlocked + * portal stays available through that copy. + */ + readonly items = computed(() => { + const { entries, matches } = this.loaded(); + const grouped = groupTitleMatchesByKey( + this.titleMatch.visibleMatches(matches) + ); + return entries.map((entry) => ({ + ...entry, + match: this.matchFor(entry, grouped), + })); + }); readonly loading = signal(false); private loadedOnce = false; @@ -55,14 +74,7 @@ export class DashboardTrendingService { const matches = await this.titleMatch.matchTitles( entries.map((entry) => entry.title) ); - const grouped = groupTitleMatchesByKey(matches); - - this.items.set( - entries.map((entry) => ({ - ...entry, - match: this.matchFor(entry, grouped), - })) - ); + this.loaded.set({ entries, matches }); this.loadedOnce = true; } catch (error) { console.warn('Dashboard trending load failed:', error);