mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -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
+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