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 b5966b302..d049b35b0 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -157,7 +157,12 @@ routes, the dashboard trending and recommendation rails, the four `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. +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/services/src/lib/cross-portal-similar.service.spec.ts b/libs/services/src/lib/cross-portal-similar.service.spec.ts index 6a69ceabc..9883b7d9b 100644 --- a/libs/services/src/lib/cross-portal-similar.service.spec.ts +++ b/libs/services/src/lib/cross-portal-similar.service.spec.ts @@ -42,6 +42,8 @@ describe('CrossPortalSimilarService', () => { matchTitles, isWithheld: (m: CatalogTitleMatch) => withheld.has(m.xtreamId), + visibleMatches: (ms: CatalogTitleMatch[]) => + ms.filter((m) => !withheld.has(m.xtreamId)), }, }, ], @@ -151,4 +153,28 @@ describe('CrossPortalSimilarService', () => { 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 85f523a35..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,18 +91,45 @@ export class CrossPortalSimilarService { posterUrl: recommendation.posterUrl, year: recommendation.year, match, + candidates: titleMatchCandidates(lookup, grouped), }); } 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. + * `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[] { - return items.filter((item) => !this.titleMatch.isWithheld(item.match)); + 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 */ @@ -110,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/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 3427360d1..b2db2dd2e 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 @@ -127,6 +127,8 @@ describe('DashboardRecommendationsService', () => { isAvailable: options.matchingAvailable ?? true, matchTitles, isWithheld, + visibleMatches: (matches: CatalogTitleMatch[]) => + matches.filter((m) => !isWithheld(m)), }, }, { @@ -195,6 +197,28 @@ describe('DashboardRecommendationsService', () => { 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(); 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 f219e6cee..c19d5b8c1 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 @@ -2,10 +2,11 @@ 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,16 +68,21 @@ export class DashboardRecommendationsService { private readonly titleMatch = inject(CatalogTitleMatchService); private readonly data = inject(DashboardDataService); - private readonly matchedItems = signal([]); + /** The last load's candidates and ALL their catalog matches. */ + private readonly loaded = signal(EMPTY_LOAD); 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. + * 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 visible = this.matchedItems().filter( - (item) => !this.titleMatch.isWithheld(item.match) + const { candidates, matches } = this.loaded(); + const visible = buildRecommendationItems( + candidates, + this.titleMatch.visibleMatches(matches), + MAX_ITEMS ); return visible.length < MIN_RECOMMENDATION_MATCHES ? [] : visible; }); @@ -104,7 +116,7 @@ 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.matchedItems.set([]); + this.loaded.set(EMPTY_LOAD); this.matchedSeedTitles.set([]); this.loadedKey = null; return; @@ -142,12 +154,17 @@ 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.matchedItems.set(matched); + this.loaded.set(load); this.matchedSeedTitles.set( perSeed .map((seed) => seed.seedTitle) @@ -175,7 +192,7 @@ 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.matchedItems.set([]); + this.loaded.set(EMPTY_LOAD); this.matchedSeedTitles.set([]); this.loadedKey = null; } @@ -200,7 +217,7 @@ export class DashboardRecommendationsService { * under the match threshold hides the rail, as everywhere else. */ private dropExcludedCards(excluded: ExclusionIndex): void { - const current = this.matchedItems(); + 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 @@ -208,12 +225,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; } @@ -223,14 +246,19 @@ 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.matchedItems.set([]); + const cards = buildRecommendationItems( + kept.candidates, + kept.matches, + MAX_ITEMS + ); + if (cards.length < MIN_RECOMMENDATION_MATCHES) { + this.loaded.set(EMPTY_LOAD); this.matchedSeedTitles.set([]); return; } - const contributed = new Set(kept.map((item) => item.seedTitle)); - this.matchedItems.set(kept); + const contributed = new Set(cards.map((item) => item.seedTitle)); + this.loaded.set(kept); this.matchedSeedTitles.set( this.matchedSeedTitles().filter((title) => contributed.has(title)) ); @@ -425,12 +453,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[] = []; @@ -441,29 +469,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.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-trending.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.spec.ts index fd3fe96cb..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 @@ -60,6 +60,8 @@ describe('DashboardTrendingService', () => { isAvailable: options.matchingAvailable ?? true, matchTitles, isWithheld, + visibleMatches: (matches: CatalogTitleMatch[]) => + matches.filter((m) => !isWithheld(m)), }, }, ], @@ -116,6 +118,19 @@ describe('DashboardTrendingService', () => { 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 43011f31b..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 @@ -26,19 +26,26 @@ export class DashboardTrendingService { private readonly enrichment = inject(TmdbEnrichmentService); private readonly titleMatch = inject(CatalogTitleMatchService); - private readonly matchedItems = signal([]); + private readonly loaded = signal<{ + entries: TmdbTrendingEntry[]; + matches: CatalogTitleMatch[]; + }>({ entries: [], matches: [] }); /** - * 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. + * 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(() => - this.matchedItems().map((item) => - item.match && this.titleMatch.isWithheld(item.match) - ? { ...item, match: null } - : item - ) - ); + 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; @@ -67,14 +74,7 @@ export class DashboardTrendingService { const matches = await this.titleMatch.matchTitles( entries.map((entry) => entry.title) ); - const grouped = groupTitleMatchesByKey(matches); - - this.matchedItems.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);