mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(settings): hide cached title matches the parental lock withholds
Cross-playlist title matches are cached by their consumers (Actor and Discover routes, the dashboard trending and recommendation rails and the four "similar in your portals" rails), so matches found while unlocked kept advertising locked titles and their playlist names after a relock. Each consumer now filters on read through a reactive predicate on the match's provider category, so a relock hides them at once and an unlock shows them again, without a re-query. Closes #1723 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
76dd8c099e
commit
19e25f1e4c
22 files changed
+289
-51
No files matched your search
@@ -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
|
||||
|
||||
@@ -73,8 +73,11 @@ export class StalkerActorRouteComponent {
|
||||
readonly isMatchingGlobal = signal(false);
|
||||
private readonly globalMatches = signal<CatalogTitleMatch[] | null>(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<ActorViewItem[]>(() => {
|
||||
|
||||
@@ -79,8 +79,11 @@ export class StalkerDiscoverRouteComponent {
|
||||
private readonly globalMatches = signal<CatalogTitleMatch[] | null>(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<DiscoverItem[]>(() => {
|
||||
|
||||
+9
-3
@@ -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<CrossPortalSimilarItem[]>([]);
|
||||
private readonly similarInPortalsMatched = signal<CrossPortalSimilarItem[]>(
|
||||
[]
|
||||
);
|
||||
/** 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ActorViewItem[]>(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<DiscoverItem[]>(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -312,6 +312,7 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
isAvailable: false,
|
||||
buildLink: jest.fn(),
|
||||
matchRecommendations: jest.fn(),
|
||||
visible: <T>(items: T[]) => items,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<string>());
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ describe('CrossPortalSimilarService', () => {
|
||||
|
||||
let matchTitles: jest.Mock;
|
||||
let isAvailable: boolean;
|
||||
let withheld: Set<number>;
|
||||
|
||||
// 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]);
|
||||
});
|
||||
});
|
||||
@@ -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<T extends CrossPortalSimilarItem>(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 [
|
||||
|
||||
@@ -271,6 +271,7 @@ describe('VodDetailsComponent offline playback', () => {
|
||||
isAvailable: false,
|
||||
matchRecommendations: jest.fn().mockResolvedValue([]),
|
||||
buildLink: jest.fn(),
|
||||
visible: <T>(items: T[]) => items,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<CrossPortalSimilarItem[]>([]);
|
||||
private readonly similarInPortalsMatched = signal<CrossPortalSimilarItem[]>(
|
||||
[]
|
||||
);
|
||||
/** 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+31
-10
@@ -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<number>());
|
||||
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 () => {
|
||||
|
||||
@@ -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<DashboardRecommendationItem[]>([]);
|
||||
private readonly matchedItems = signal<DashboardRecommendationItem[]>([]);
|
||||
private readonly matchedSeedTitles = signal<readonly string[]>([]);
|
||||
/**
|
||||
* 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 string[]>([]);
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<number>());
|
||||
const isWithheld = (match: CatalogTitleMatch) =>
|
||||
withheldCategories().has(match.categoryId);
|
||||
|
||||
const entry = (
|
||||
overrides: Partial<TmdbTrendingEntry> = {}
|
||||
): 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 }),
|
||||
|
||||
@@ -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<DashboardTrendingItem[]>([]);
|
||||
private readonly matchedItems = signal<DashboardTrendingItem[]>([]);
|
||||
/**
|
||||
* 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),
|
||||
|
||||
Reference in new issue
Block a user