mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
fix(settings): hide cached title matches the parental lock withholds (#1735)
* 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> * fix(settings): fall back to unlocked copies of a withheld title match The dashboard trending and recommendation rails and the "similar in your portals" rails picked one match per title at load, so hiding a withheld match lost a copy of the same title in an unlocked portal. They now keep every row the lookup returned and pick the match on read from the rows the parental lock does not withhold. Adds the release note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): derive the recommendations seed heading from the visible cards The rail kept only the seeds that contributed a card to the original selection, so a seed whose recommendations filled in after a relock (or an offline prune) was missing from the "Because you watched" heading. Every seed of the load is kept in order and the heading lists those that contribute a card now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
25 files changed
+547
-118
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<T extends CrossPortalSimilarItem>(items: readonly T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
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<string, CatalogTitleMatch[]>
|
||||
): CatalogTitleMatch[] {
|
||||
const candidates: CatalogTitleMatch[] = [];
|
||||
for (const title of lookup.titles) {
|
||||
const key = `${lookup.type}:${normalizeTitleKeys(title).exact}`;
|
||||
candidates.push(...(grouped.get(key) ?? []));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+87
-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,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']);
|
||||
});
|
||||
});
|
||||
@@ -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<DashboardRecommendationItem[]>([]);
|
||||
/** The last load's candidates and ALL their catalog matches. */
|
||||
private readonly loaded = signal<LoadedRecommendations>(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<readonly string[]>([]);
|
||||
/**
|
||||
* 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 string[]>([]);
|
||||
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<DashboardRecommendationItem[]> {
|
||||
): Promise<LoadedRecommendations> {
|
||||
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<string>();
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 }),
|
||||
|
||||
@@ -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<DashboardTrendingItem[]>([]);
|
||||
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<DashboardTrendingItem[]>(() => {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user