mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
fix(dashboard): give the hero the identity the detail view matched with (#1362)
The dashboard hero showed no backdrop for items whose detail page had one. Two independent causes, both about identity rather than the matching gate. Stalker items never reach the `content` table, so the xtream back-fill of `content.backdrop_url` has no equivalent for them — but the enriched backdrop is already sitting in the stored playlist entry (`info.tmdb_backdrop`). The activity mappers now surface it as `backdrop_url`, where the hero already looks first. The hero's TMDB lookup ran on the display title alone, while the detail view searched with the original title and the release year. Without a year `pickConfidentMatch` requires a single exact title match, which common titles never satisfy, and the miss lands in the negative cache under a lookup key the detail view's hit can never be found at. The query is now built from the same fields (`extractStalkerItemTmdbHints`), and the resolved `tmdb_id` short-circuits the search entirely. A 'movie' verdict retries as 'tv' without the id — 'movie' is the answer every row falls back to, and an id is valid only for its own media type. A 'tv' verdict, reached only on positive series evidence, gets no retry back to 'movie'. Also removes `buildStalkerRecentItems`, a dead duplicate of the mapper the dashboard actually uses.
This commit is contained in:
1 parent
b92503feae
commit
83f6a270a5
15 files changed
+660
-109
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: dashboard
|
||||
---
|
||||
|
||||
The dashboard hero now shows the same backdrop as the detail page. Stalker
|
||||
movies and series reuse the artwork saved when you opened them, and the
|
||||
metadata lookup behind the hero uses the release year and original title
|
||||
instead of the bare title — so titles TMDB knows more than once ("Холод")
|
||||
no longer come up empty.
|
||||
@@ -1010,7 +1010,7 @@ engine` (restart required) or
|
||||
- Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works
|
||||
- "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation
|
||||
- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`)
|
||||
- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per session; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint
|
||||
- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per lookup identity; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint. The hero lookup must carry the same identity the detail view used, not just the display title — `extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads title/original title/year/tmdb id off a stored Stalker entry; a `movie` verdict retries as `tv` without the id (the default answer earns a retry, and an id is valid only for its own media type), while a `tv` verdict — reached only on positive series evidence — gets no retry back to `movie`. Stalker items never reach the `content` table, so their backdrop rides in the stored entry (`info.tmdb_backdrop`) rather than `content.backdrop_url`, and the activity mappers surface it as `backdrop_url`
|
||||
- Series detail views show a TMDB production-status chip (`tmdb_status`, e.g. Ended / Returning) — TMDB sends `status` in English regardless of request language, so it is normalized to a token by `normalizeSeriesStatus` and rendered via `seriesStatusLabelKey` translations; person pages show `deathday` alongside `birthday`
|
||||
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-credits.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`)
|
||||
- Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts`
|
||||
|
||||
@@ -405,15 +405,49 @@ dashboard rail, artwork upgrade for M3U VOD, persistent PWA cache
|
||||
the worker at startup, and runs once per app session.
|
||||
- **Hero extras**: `DashboardHeroTmdbService`
|
||||
(`libs/workspace/dashboard/feature`) patches the hero card with a TMDB
|
||||
backdrop (when the provider item has none), a rating badge and up to two
|
||||
backdrop (when the activity row has none), a rating badge and up to two
|
||||
genre chips — resolved through the enrichment facade, so items already
|
||||
opened in a detail view come from the SQLite cache without network.
|
||||
Results are memoized per title for the session. The hero renders
|
||||
Results are memoized per lookup identity for the session. The hero renders
|
||||
immediately from provider data; extras appear when resolved. Series
|
||||
heroes additionally show the tracked "S{n}·E{n}" badge from the playback
|
||||
position (no TMDB involved); the watch-progress bar is limited to
|
||||
movie/series heroes.
|
||||
|
||||
The query is built to **match what the detail view searched with**, not
|
||||
just what the card displays. A title alone is weaker identity than the
|
||||
detail page had: without a year `pickConfidentMatch` requires a single
|
||||
exact title match, which common titles never satisfy, and the miss lands
|
||||
in the negative cache under a lookup key the detail view's hit can never
|
||||
be found at. Stalker rows carry those facts —
|
||||
`extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads
|
||||
`info.name`, `info.o_name`, `info.releasedate` and `info.tmdb_id` off the
|
||||
stored entry, mirroring `enrichStalkerSelectionWithTmdb` field for field.
|
||||
A `'movie'` verdict gets a second attempt under `'tv'` — `'movie'` is what
|
||||
every row falls back to when nothing says otherwise, and an embedded-VOD
|
||||
series is a `'movie'` activity row but a show on TMDB. That retry **drops
|
||||
the id**, which is only ever valid for the media type it was resolved
|
||||
under: `/movie/<tv id>` resolves to an unrelated film. A `'tv'` verdict
|
||||
gets no retry — it is reached only on positive evidence (series category,
|
||||
`is_series`, or a non-empty episode array), and the gate cannot tell an
|
||||
adaptation sharing its show's name and year from the show itself.
|
||||
|
||||
Note the id in a stored Stalker entry is not a provider claim. Stalker
|
||||
portals never send one; its only source is a match this app already made
|
||||
and gated.
|
||||
|
||||
### Stalker backdrops on activity rows
|
||||
|
||||
Xtream detail views back-fill `content.backdrop_url`, which the dashboard
|
||||
reads directly. Stalker items never reach the `content` table, so their
|
||||
backdrop travels inside the stored playlist entry instead
|
||||
(`info.tmdb_backdrop`, written by the same merge that enriched the detail
|
||||
view) and the activity mappers surface it as `backdrop_url` —
|
||||
`mapStalkerPlaylistRecentItems` in `playlist-recently-viewed.utils.ts` and
|
||||
`buildStalkerFavoriteItems` in `dashboard-mappers.ts`. Entries saved before
|
||||
enrichment (or with TMDB off) simply have none, and the dashboard falls back
|
||||
to a blurred poster.
|
||||
|
||||
### `badProviderId:` rows
|
||||
|
||||
Providers ship `tmdb_id` values that do not exist. A failed details fetch
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Observable } from 'rxjs';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import {
|
||||
createStalkerVodItem,
|
||||
isStalkerSeriesFlag,
|
||||
isStalkerSeriesItem,
|
||||
StalkerVodDetails,
|
||||
StalkerVodInfo,
|
||||
VodDetailsItem,
|
||||
@@ -47,9 +49,10 @@ export interface StalkerFavoriteToggleOptions {
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function isStalkerSeriesFlag(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === '1';
|
||||
}
|
||||
// Single definition, shared with the activity-item normalizer and the TMDB
|
||||
// hints reader: the dashboard has to reproduce the media type this predicate
|
||||
// picks here, and a second copy would drift away from it.
|
||||
export { isStalkerSeriesFlag, isStalkerSeriesItem };
|
||||
|
||||
export function normalizeStalkerSeriesFlag(
|
||||
value: unknown
|
||||
@@ -57,16 +60,6 @@ export function normalizeStalkerSeriesFlag(
|
||||
return isStalkerSeriesFlag(value) ? true : undefined;
|
||||
}
|
||||
|
||||
export function isStalkerSeriesItem(item: {
|
||||
is_series?: unknown;
|
||||
series?: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
isStalkerSeriesFlag(item?.is_series) ||
|
||||
(Array.isArray(item?.series) && item.series.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildStalkerFavoritePayload(
|
||||
data: StalkerVodDetails
|
||||
): Record<string, unknown> {
|
||||
|
||||
@@ -68,6 +68,7 @@ export * from './lib/xtream-vod-stream.interface';
|
||||
|
||||
// Stalker interfaces
|
||||
export * from './lib/stalker-item.normalizer';
|
||||
export * from './lib/stalker-item-tmdb-hints';
|
||||
export * from './lib/stalker-identity.utils';
|
||||
export * from './lib/stalker-request-identity.util';
|
||||
export * from './lib/stalker-request-url.util';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PlaylistMeta } from './playlist-meta.type';
|
||||
import { PortalRecentItem } from './portal-activity-item.interface';
|
||||
import { extractStalkerItemTmdbHints } from './stalker-item-tmdb-hints';
|
||||
import {
|
||||
extractStalkerItemId,
|
||||
extractStalkerItemPoster,
|
||||
@@ -52,6 +53,10 @@ function mapStalkerPlaylistRecentItems(
|
||||
category_id: String(item['category_id'] ?? ''),
|
||||
xtream_id: id,
|
||||
poster_url: extractStalkerItemPoster(item),
|
||||
// Stalker has no `content` row to back-fill, so the backdrop
|
||||
// travels inside the stored entry itself — present once the
|
||||
// detail view has enriched the item.
|
||||
backdrop_url: extractStalkerItemTmdbHints(item).backdropUrl,
|
||||
source: 'stalker',
|
||||
stalker_item: rawItem,
|
||||
});
|
||||
|
||||
@@ -22,10 +22,13 @@ export interface PortalActivityItem {
|
||||
*/
|
||||
epg_lookup_key?: string;
|
||||
/**
|
||||
* Wide backdrop URL (typically 16:9), persisted when the user first views
|
||||
* the detail page. Absent for stalker and for items that have never had
|
||||
* their detail page opened. Dashboards should fall back to a blurred
|
||||
* `poster_url` when undefined.
|
||||
* Wide backdrop URL (typically 16:9), recorded when the user first views
|
||||
* the detail page — for xtream on the `content` row, for stalker inside
|
||||
* the stored playlist entry (`info.tmdb_backdrop`), since stalker items
|
||||
* never reach the `content` table. Absent for items whose detail page has
|
||||
* never been opened, and for stalker items opened without TMDB
|
||||
* enrichment. Dashboards should fall back to a blurred `poster_url` when
|
||||
* undefined.
|
||||
*/
|
||||
backdrop_url?: string;
|
||||
source?: 'xtream' | 'stalker' | 'm3u';
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { buildPlaylistRecentItems } from './playlist-recently-viewed.utils';
|
||||
import type { PlaylistMeta } from './playlist-meta.type';
|
||||
import { extractStalkerItemTmdbHints } from './stalker-item-tmdb-hints';
|
||||
import { extractStalkerItemType } from './stalker-item.normalizer';
|
||||
|
||||
/**
|
||||
* The shape a Stalker recently-viewed entry has after the detail view
|
||||
* enriched it: an embedded-VOD ("vclub") series, so `series` is populated
|
||||
* while the category stays VOD.
|
||||
*/
|
||||
const enrichedEntry = {
|
||||
id: '17573',
|
||||
cmd: '/media/17573.mpg',
|
||||
series: [1, 2, 3],
|
||||
category_id: '7',
|
||||
cover: 'https://image.tmdb.org/t/p/w500/poster.jpg',
|
||||
title: 'Холод (10 серий)',
|
||||
added_at: '2026-07-20T18:19:18.979Z',
|
||||
info: {
|
||||
name: 'Холод (10 серий)',
|
||||
o_name: 'Kholod',
|
||||
releasedate: '2026',
|
||||
tmdb_id: 318354,
|
||||
tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/backdrop.jpg',
|
||||
},
|
||||
};
|
||||
|
||||
describe('extractStalkerItemTmdbHints', () => {
|
||||
it('reads the facts the detail view merged into the stored entry', () => {
|
||||
expect(extractStalkerItemTmdbHints(enrichedEntry)).toEqual({
|
||||
backdropUrl: 'https://image.tmdb.org/t/p/w1280/backdrop.jpg',
|
||||
mediaType: 'tv',
|
||||
originalTitle: 'Kholod',
|
||||
tmdbId: 318354,
|
||||
title: 'Холод (10 серий)',
|
||||
year: 2026,
|
||||
});
|
||||
});
|
||||
|
||||
it('types embedded-VOD series as tv even though the activity row is a movie', () => {
|
||||
// The routing type must stay 'movie' (these live in the VOD section)
|
||||
// while the TMDB lookup has to run against /tv — the two answers
|
||||
// diverge on purpose.
|
||||
expect(extractStalkerItemType(enrichedEntry)).toBe('movie');
|
||||
expect(extractStalkerItemTmdbHints(enrichedEntry).mediaType).toBe('tv');
|
||||
});
|
||||
|
||||
it('types regular series-category items as tv', () => {
|
||||
expect(
|
||||
extractStalkerItemTmdbHints({
|
||||
id: '9',
|
||||
category_id: 'series',
|
||||
info: { name: 'The Boys' },
|
||||
}).mediaType
|
||||
).toBe('tv');
|
||||
});
|
||||
|
||||
it('types a plain VOD item as movie', () => {
|
||||
expect(
|
||||
extractStalkerItemTmdbHints({
|
||||
id: '9',
|
||||
category_id: 'vod',
|
||||
info: { name: 'Heat', releasedate: '1995-12-15' },
|
||||
})
|
||||
).toEqual({
|
||||
backdropUrl: undefined,
|
||||
mediaType: 'movie',
|
||||
originalTitle: undefined,
|
||||
tmdbId: undefined,
|
||||
title: 'Heat',
|
||||
year: 1995,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a year inside the title when there is no release date', () => {
|
||||
expect(
|
||||
extractStalkerItemTmdbHints({
|
||||
info: { name: 'Dune (2021)' },
|
||||
}).year
|
||||
).toBe(2021);
|
||||
});
|
||||
|
||||
it('reports no title when the entry carries no info block', () => {
|
||||
// Callers keep their own display title instead of searching for a
|
||||
// placeholder such as "Unknown".
|
||||
expect(extractStalkerItemTmdbHints({ id: '1', title: 'X' })).toEqual({
|
||||
backdropUrl: undefined,
|
||||
mediaType: 'movie',
|
||||
originalTitle: undefined,
|
||||
tmdbId: undefined,
|
||||
title: undefined,
|
||||
year: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores blank and non-positive-integer values', () => {
|
||||
expect(
|
||||
extractStalkerItemTmdbHints({
|
||||
info: {
|
||||
name: 'X',
|
||||
o_name: ' ',
|
||||
tmdb_backdrop: '',
|
||||
tmdb_id: 'not-a-number',
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
backdropUrl: undefined,
|
||||
mediaType: 'movie',
|
||||
originalTitle: undefined,
|
||||
tmdbId: undefined,
|
||||
title: 'X',
|
||||
year: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates a missing item', () => {
|
||||
expect(extractStalkerItemTmdbHints(undefined).mediaType).toBe('movie');
|
||||
expect(extractStalkerItemTmdbHints(null).year).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPlaylistRecentItems (stalker backdrops)', () => {
|
||||
const labels = { stalker: 'Stalker', m3u: 'M3U' };
|
||||
|
||||
function recentItemsOf(rawItem: unknown) {
|
||||
return buildPlaylistRecentItems(
|
||||
[
|
||||
{
|
||||
_id: 'portal-1',
|
||||
title: 'RUcolor.tv',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
recentlyViewed: [rawItem],
|
||||
} as unknown as PlaylistMeta,
|
||||
],
|
||||
labels
|
||||
);
|
||||
}
|
||||
|
||||
it('surfaces the enriched backdrop so the hero does not need a lookup', () => {
|
||||
expect(recentItemsOf(enrichedEntry)[0].backdrop_url).toBe(
|
||||
'https://image.tmdb.org/t/p/w1280/backdrop.jpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the backdrop undefined for an entry saved without enrichment', () => {
|
||||
expect(
|
||||
recentItemsOf({
|
||||
id: '17573',
|
||||
title: 'Холод (10 серий)',
|
||||
cover: 'https://image.tmdb.org/t/p/w500/poster.jpg',
|
||||
})[0].backdrop_url
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { isStalkerSeriesItem } from './stalker-item.normalizer';
|
||||
import { StalkerPortalItem } from './stalker-portal-item.interface';
|
||||
import { extractYear } from './title-normalization.util';
|
||||
import { TmdbMediaType } from './tmdb.interface';
|
||||
|
||||
/**
|
||||
* What a stored Stalker favorite / recently-viewed entry already knows about
|
||||
* its TMDB match.
|
||||
*
|
||||
* These items are written as a spread of the item the detail view had on
|
||||
* screen, so an entry saved after enrichment carries the merged result:
|
||||
* the backdrop, the resolved id, the release date and the original title.
|
||||
* Anything reading such an entry (the dashboard hero above all) should use
|
||||
* those facts instead of starting a fresh title search — the search runs on
|
||||
* strictly less identity than the detail view had, and the confidence gate
|
||||
* turns that difference into a miss.
|
||||
*/
|
||||
export interface StalkerItemTmdbHints {
|
||||
/** Wide backdrop merged in by the detail view, when it got that far */
|
||||
readonly backdropUrl?: string;
|
||||
/**
|
||||
* The media type the detail view enriched under — NOT the activity type.
|
||||
* Embedded-VOD series stay `'movie'` as activity rows (they route into
|
||||
* the VOD section) while TMDB knows them as shows.
|
||||
*/
|
||||
readonly mediaType: TmdbMediaType;
|
||||
readonly originalTitle?: string;
|
||||
/**
|
||||
* TMDB id from a previous merge. Unlike an Xtream `tmdb_id`, this is not
|
||||
* a provider claim — Stalker portals never send one, so its only source
|
||||
* is a match this app already made and gated.
|
||||
*/
|
||||
readonly tmdbId?: number;
|
||||
/**
|
||||
* The title the detail view searched with (`info.name`). Absent when the
|
||||
* entry carries no `info`, in which case the caller keeps its own display
|
||||
* title — never a placeholder that would search for nothing.
|
||||
*/
|
||||
readonly title?: string;
|
||||
readonly year: number | null;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
function readTmdbId(value: unknown): number | undefined {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the TMDB facts a stored Stalker item carries. The query fields mirror
|
||||
* `enrichStalkerSelectionWithTmdb` exactly — same title, same original
|
||||
* title, same year, same media type — so a lookup built from these hints
|
||||
* lands on the cache row the detail view already filled instead of minting a
|
||||
* second, weaker one.
|
||||
*/
|
||||
export function extractStalkerItemTmdbHints(
|
||||
item: StalkerPortalItem | Record<string, unknown> | null | undefined
|
||||
): StalkerItemTmdbHints {
|
||||
const raw = (item ?? {}) as Record<string, unknown>;
|
||||
const info = (raw['info'] ?? {}) as Record<string, unknown>;
|
||||
|
||||
const title = readString(info['name']);
|
||||
const categoryId = String(raw['category_id'] ?? '').toLowerCase();
|
||||
|
||||
return {
|
||||
backdropUrl: readString(info['tmdb_backdrop']),
|
||||
mediaType:
|
||||
categoryId === 'series' || isStalkerSeriesItem(raw)
|
||||
? 'tv'
|
||||
: 'movie',
|
||||
originalTitle: readString(info['o_name']),
|
||||
tmdbId: readTmdbId(info['tmdb_id']),
|
||||
title,
|
||||
year: extractYear(readString(info['releasedate']), title),
|
||||
};
|
||||
}
|
||||
@@ -46,12 +46,37 @@ export function extractStalkerItemPoster(
|
||||
return String(raw['cover'] ?? raw['logo'] ?? raw['poster_url'] ?? '');
|
||||
}
|
||||
|
||||
/** Portals spell the series marker as `true`, `1`, or `'1'`. */
|
||||
export function isStalkerSeriesFlag(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this item behave like a series? Beyond the explicit flag, an
|
||||
* embedded-VOD ("vclub") item announces its episodes through a non-empty
|
||||
* `series` array while staying in the VOD category.
|
||||
*/
|
||||
export function isStalkerSeriesItem(item: {
|
||||
is_series?: unknown;
|
||||
series?: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
isStalkerSeriesFlag(item?.is_series) ||
|
||||
(Array.isArray(item?.series) && item.series.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the normalised activity type of a Stalker item.
|
||||
*
|
||||
* - `itv` / `live` / radio → `'live'`
|
||||
* - `series` or `is_series` equal to `true`, `1`, or `'1'` → `'series'`
|
||||
* - everything else → `'movie'`
|
||||
*
|
||||
* Deliberately blind to the `series` episode array: embedded-VOD items live
|
||||
* in the VOD section and must keep routing there, so they stay `'movie'`
|
||||
* here even though TMDB knows them as shows. Metadata lookups need the other
|
||||
* answer — see {@link isStalkerSeriesItem}.
|
||||
*/
|
||||
export function extractStalkerItemType(
|
||||
item: StalkerPortalItem | Record<string, unknown>
|
||||
@@ -68,12 +93,7 @@ export function extractStalkerItemType(
|
||||
return 'live';
|
||||
}
|
||||
|
||||
if (
|
||||
categoryId === 'series' ||
|
||||
raw['is_series'] === true ||
|
||||
raw['is_series'] === 1 ||
|
||||
raw['is_series'] === '1'
|
||||
) {
|
||||
if (categoryId === 'series' || isStalkerSeriesFlag(raw['is_series'])) {
|
||||
return 'series';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { buildStalkerFavoriteItems } from './dashboard-mappers';
|
||||
|
||||
describe('buildStalkerFavoriteItems', () => {
|
||||
function favoritesOf(favorite: unknown) {
|
||||
return buildStalkerFavoriteItems(
|
||||
[
|
||||
{
|
||||
_id: 'portal-1',
|
||||
title: 'RUcolor.tv',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
favorites: [favorite],
|
||||
} as unknown as PlaylistMeta,
|
||||
],
|
||||
'Stalker'
|
||||
);
|
||||
}
|
||||
|
||||
it('surfaces the backdrop the detail view merged into the entry', () => {
|
||||
// Stalker items never reach the `content` table, so the enriched
|
||||
// backdrop travels inside the stored favorite itself.
|
||||
const [item] = favoritesOf({
|
||||
id: '17573',
|
||||
category_id: '7',
|
||||
title: 'Холод (10 серий)',
|
||||
cover: 'https://image.tmdb.org/t/p/w500/poster.jpg',
|
||||
info: {
|
||||
name: 'Холод (10 серий)',
|
||||
tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/backdrop.jpg',
|
||||
},
|
||||
});
|
||||
|
||||
expect(item.backdrop_url).toBe(
|
||||
'https://image.tmdb.org/t/p/w1280/backdrop.jpg'
|
||||
);
|
||||
expect(item.poster_url).toBe(
|
||||
'https://image.tmdb.org/t/p/w500/poster.jpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the backdrop undefined for an entry saved without enrichment', () => {
|
||||
const [item] = favoritesOf({
|
||||
id: '17573',
|
||||
title: 'Холод (10 серий)',
|
||||
cover: 'https://image.tmdb.org/t/p/w500/poster.jpg',
|
||||
});
|
||||
|
||||
expect(item.backdrop_url).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
extractStalkerItemId,
|
||||
extractStalkerItemPoster,
|
||||
extractStalkerItemTitle,
|
||||
extractStalkerItemTmdbHints,
|
||||
extractStalkerItemType,
|
||||
normalizeStalkerDate,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -83,44 +84,6 @@ export function mapDbRecentlyAddedToItem(
|
||||
|
||||
// ────── Stalker playlist → ViewModel ──────
|
||||
|
||||
export function buildStalkerRecentItems(
|
||||
playlists: PlaylistMeta[],
|
||||
defaultPlaylistName: string
|
||||
): PortalRecentItem[] {
|
||||
return playlists
|
||||
.filter((playlist) => Boolean(playlist.macAddress))
|
||||
.reduce<PortalRecentItem[]>((acc, playlist) => {
|
||||
const recentItems = Array.isArray(
|
||||
(playlist as { recentlyViewed?: unknown[] }).recentlyViewed
|
||||
)
|
||||
? ((playlist as { recentlyViewed?: unknown[] })
|
||||
.recentlyViewed ?? [])
|
||||
: [];
|
||||
|
||||
const mapped = recentItems.map((rawItem, index) => {
|
||||
const item = (rawItem ?? {}) as Record<string, unknown>;
|
||||
const id = extractStalkerItemId(item, playlist._id, index);
|
||||
|
||||
return {
|
||||
id,
|
||||
title: extractStalkerItemTitle(item),
|
||||
type: extractStalkerItemType(item),
|
||||
playlist_id: playlist._id,
|
||||
playlist_name: playlist.title || defaultPlaylistName,
|
||||
viewed_at: normalizeStalkerDate(item['added_at']),
|
||||
category_id: String(item['category_id'] ?? ''),
|
||||
xtream_id: id,
|
||||
poster_url: extractStalkerItemPoster(item),
|
||||
source: 'stalker' as const,
|
||||
stalker_item: rawItem,
|
||||
} as PortalRecentItem;
|
||||
});
|
||||
|
||||
acc.push(...mapped);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function buildStalkerFavoriteItems(
|
||||
playlists: PlaylistMeta[],
|
||||
defaultPlaylistName: string
|
||||
@@ -146,6 +109,9 @@ export function buildStalkerFavoriteItems(
|
||||
category_id: String(raw['category_id'] ?? ''),
|
||||
xtream_id: id,
|
||||
poster_url: extractStalkerItemPoster(raw),
|
||||
// Same source as the recent-items mapper: Stalker keeps
|
||||
// the enriched backdrop inside the stored entry.
|
||||
backdrop_url: extractStalkerItemTmdbHints(raw).backdropUrl,
|
||||
source: 'stalker' as const,
|
||||
stalker_item: item,
|
||||
} as PortalFavoriteItem;
|
||||
|
||||
+135
-3
@@ -1,6 +1,9 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TmdbEnrichmentService } from '@iptvnator/services';
|
||||
import { DashboardHeroTmdbService } from './dashboard-hero-tmdb.service';
|
||||
import {
|
||||
DashboardHeroTmdbItem,
|
||||
DashboardHeroTmdbService,
|
||||
} from './dashboard-hero-tmdb.service';
|
||||
|
||||
describe('DashboardHeroTmdbService', () => {
|
||||
const tvDetails = {
|
||||
@@ -11,6 +14,44 @@ describe('DashboardHeroTmdbService', () => {
|
||||
genres: [{ id: 1, name: 'Drama' }, { id: 2, name: 'Comedy' }],
|
||||
};
|
||||
|
||||
/**
|
||||
* A Stalker recently-viewed entry as stored after the detail view merged
|
||||
* TMDB into it: an embedded-VOD series, so the activity row is typed
|
||||
* 'movie' while TMDB knows it as a show.
|
||||
*/
|
||||
const stalkerItem: DashboardHeroTmdbItem = {
|
||||
title: 'Холод (10 серий)',
|
||||
type: 'movie',
|
||||
stalker_item: {
|
||||
id: '17573',
|
||||
category_id: '7',
|
||||
series: [1, 2, 3],
|
||||
title: 'Холод (10 серий)',
|
||||
info: {
|
||||
name: 'Холод (10 серий)',
|
||||
o_name: 'Kholod',
|
||||
releasedate: '2026',
|
||||
tmdb_id: 318354,
|
||||
},
|
||||
},
|
||||
} as unknown as DashboardHeroTmdbItem;
|
||||
|
||||
/** A plain VOD entry: nothing marks it as a series, so it leads with movie */
|
||||
const plainVodItem = {
|
||||
title: 'The Matrix',
|
||||
type: 'movie',
|
||||
stalker_item: {
|
||||
id: '42',
|
||||
category_id: 'vod',
|
||||
title: 'The Matrix',
|
||||
info: {
|
||||
name: 'The Matrix',
|
||||
releasedate: '1999-03-31',
|
||||
tmdb_id: 603,
|
||||
},
|
||||
},
|
||||
} as unknown as DashboardHeroTmdbItem;
|
||||
|
||||
let isEnabled: jest.Mock;
|
||||
let enrichMovie: jest.Mock;
|
||||
let enrichTv: jest.Mock;
|
||||
@@ -48,7 +89,6 @@ describe('DashboardHeroTmdbService', () => {
|
||||
});
|
||||
|
||||
it('falls back to a TV lookup for movie-typed items without a movie match', async () => {
|
||||
// Stalker embedded-series items are typed 'movie' in activity rows
|
||||
const service = createService();
|
||||
|
||||
const extras = await service.getExtras({
|
||||
@@ -63,6 +103,20 @@ describe('DashboardHeroTmdbService', () => {
|
||||
expect(extras?.genres).toEqual(['Drama', 'Comedy']);
|
||||
});
|
||||
|
||||
it('does not retry a series-typed item as a movie', async () => {
|
||||
// 'movie' is the answer every row falls back to, so it earns a
|
||||
// retry; 'tv' is only reached on positive evidence and must not be
|
||||
// traded for a same-titled film — the gate cannot tell an
|
||||
// adaptation sharing its show's name and year from the show itself.
|
||||
enrichTv.mockResolvedValue(null);
|
||||
const service = createService();
|
||||
|
||||
await expect(
|
||||
service.getExtras({ title: 'Fargo', type: 'series' })
|
||||
).resolves.toBeNull();
|
||||
expect(enrichMovie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry as TV when the movie lookup succeeds', async () => {
|
||||
enrichMovie.mockResolvedValue({ ...tvDetails, id: 200 });
|
||||
const service = createService();
|
||||
@@ -72,7 +126,7 @@ describe('DashboardHeroTmdbService', () => {
|
||||
expect(enrichTv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('memoizes results per title and type', async () => {
|
||||
it('memoizes results per lookup identity', async () => {
|
||||
const service = createService();
|
||||
|
||||
await service.getExtras({ title: 'The Boys', type: 'series' });
|
||||
@@ -80,4 +134,82 @@ describe('DashboardHeroTmdbService', () => {
|
||||
|
||||
expect(enrichTv).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('stalker items', () => {
|
||||
it('queries with the year, original title and id the detail view used', async () => {
|
||||
// Without these the search runs on the title alone, and the
|
||||
// confidence gate rejects any title TMDB knows more than once.
|
||||
const service = createService();
|
||||
|
||||
const extras = await service.getExtras(stalkerItem);
|
||||
|
||||
expect(enrichMovie).not.toHaveBeenCalled();
|
||||
expect(enrichTv).toHaveBeenCalledWith({
|
||||
title: 'Холод (10 серий)',
|
||||
originalTitle: 'Kholod',
|
||||
tmdbId: 318354,
|
||||
year: 2026,
|
||||
});
|
||||
expect(extras?.backdropUrl).toContain('/serial.jpg');
|
||||
});
|
||||
|
||||
it('never carries the id into the other media type', async () => {
|
||||
// /movie/<tv id> resolves to an unrelated film whose details
|
||||
// would then be shown as this item's — so the retry drops it.
|
||||
enrichMovie.mockResolvedValue(null);
|
||||
const service = createService();
|
||||
|
||||
await service.getExtras(plainVodItem);
|
||||
|
||||
expect(enrichMovie).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tmdbId: 603, year: 1999 })
|
||||
);
|
||||
expect(enrichTv).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tmdbId: undefined, year: 1999 })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not retry a series as a movie', async () => {
|
||||
enrichTv.mockResolvedValue(null);
|
||||
const service = createService();
|
||||
|
||||
await expect(service.getExtras(stalkerItem)).resolves.toBeNull();
|
||||
expect(enrichMovie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not search for an entry that has no usable name', async () => {
|
||||
const service = createService();
|
||||
|
||||
await expect(
|
||||
service.getExtras({
|
||||
title: 'Unknown',
|
||||
type: 'movie',
|
||||
stalker_item: { id: '1' },
|
||||
} as unknown as DashboardHeroTmdbItem)
|
||||
).resolves.toBeNull();
|
||||
expect(enrichMovie).not.toHaveBeenCalled();
|
||||
expect(enrichTv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keys same-titled items apart by the facts behind them', async () => {
|
||||
const service = createService();
|
||||
const remake = {
|
||||
...stalkerItem,
|
||||
stalker_item: {
|
||||
...(stalkerItem.stalker_item as object),
|
||||
info: {
|
||||
name: 'Холод (10 серий)',
|
||||
releasedate: '1984',
|
||||
},
|
||||
},
|
||||
} as DashboardHeroTmdbItem;
|
||||
|
||||
expect(service.keyFor(stalkerItem)).not.toBe(service.keyFor(remake));
|
||||
|
||||
await service.getExtras(stalkerItem);
|
||||
await service.getExtras(remake);
|
||||
|
||||
expect(enrichTv).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
extractYear,
|
||||
tmdbBackdropUrl,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
TmdbMediaType,
|
||||
extractStalkerItemTmdbHints,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { GlobalRecentItem } from '@iptvnator/workspace/dashboard/data-access';
|
||||
|
||||
/** TMDB extras for the dashboard hero, patched in after first paint */
|
||||
@@ -13,13 +17,40 @@ export interface DashboardHeroTmdbExtras {
|
||||
readonly genres: readonly string[];
|
||||
}
|
||||
|
||||
/** Everything the hero lookup reads off an activity row */
|
||||
export type DashboardHeroTmdbItem = Pick<
|
||||
GlobalRecentItem,
|
||||
'title' | 'type' | 'stalker_item'
|
||||
>;
|
||||
|
||||
/**
|
||||
* One lookup attempt: a media type plus the query to run under it. The
|
||||
* TMDB id belongs to exactly one media type, so a second attempt under the
|
||||
* other one must drop it — `/movie/<tv id>` resolves to an unrelated film
|
||||
* whose details would then be rendered as this item's.
|
||||
*/
|
||||
interface HeroTmdbAttempt {
|
||||
readonly mediaType: TmdbMediaType;
|
||||
readonly title: string;
|
||||
readonly originalTitle?: string;
|
||||
readonly tmdbId?: number;
|
||||
readonly year: number | null;
|
||||
}
|
||||
|
||||
const MAX_HERO_GENRES = 2;
|
||||
|
||||
/**
|
||||
* Best-effort TMDB extras for the hero card (backdrop, rating, genres).
|
||||
* Goes through the enrichment facade, so items already opened in a detail
|
||||
* view resolve from the SQLite cache without network. Results are memoized
|
||||
* per title for the session — dashboard revisits skip the IPC round-trip.
|
||||
* per lookup identity for the session — dashboard revisits skip the IPC
|
||||
* round-trip.
|
||||
*
|
||||
* The query is built to match what the detail view searched with, not just
|
||||
* what the card displays. A title alone is weaker identity than the detail
|
||||
* page had: without a year `pickConfidentMatch` falls back to requiring a
|
||||
* single exact title match, which common titles never satisfy, and the miss
|
||||
* is cached under a lookup key the detail view's hit can never be found at.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardHeroTmdbService {
|
||||
@@ -34,68 +65,140 @@ export class DashboardHeroTmdbService {
|
||||
return this.enrichment.isEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity of the lookup for an item — the memo key, and the staleness
|
||||
* guard callers compare against while a request is in flight. Derived
|
||||
* here so the two can never disagree about what "the same hero" means.
|
||||
*/
|
||||
keyFor(item: DashboardHeroTmdbItem): string {
|
||||
const [primary] = this.buildAttempts(item);
|
||||
return primary
|
||||
? [
|
||||
primary.mediaType,
|
||||
primary.title,
|
||||
primary.originalTitle ?? '',
|
||||
primary.year ?? '',
|
||||
primary.tmdbId ?? '',
|
||||
].join('|')
|
||||
: `${item.type}:${item.title}`;
|
||||
}
|
||||
|
||||
getExtras(
|
||||
item: Pick<GlobalRecentItem, 'title' | 'type'>
|
||||
item: DashboardHeroTmdbItem
|
||||
): Promise<DashboardHeroTmdbExtras | null> {
|
||||
if (
|
||||
!this.enrichment.isEnabled() ||
|
||||
(item.type !== 'movie' && item.type !== 'series')
|
||||
) {
|
||||
const attempts = this.buildAttempts(item);
|
||||
if (!this.enrichment.isEnabled() || attempts.length === 0) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const key = `${item.type}:${item.title}`;
|
||||
const key = this.keyFor(item);
|
||||
const cached = this.memo.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const pending = this.loadExtras(item);
|
||||
const pending = this.loadExtras(attempts);
|
||||
this.memo.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered lookup attempts for one activity row. Stalker rows carry the
|
||||
* facts of the detail view's own enrichment, so they lead with those;
|
||||
* everything else can only offer the display title.
|
||||
*
|
||||
* A `'movie'` verdict gets a second attempt under `'tv'`, because
|
||||
* `'movie'` is what every row falls back to when nothing says otherwise
|
||||
* — an embedded-VOD ("vclub") series is a `'movie'` activity row, and a
|
||||
* lazily-loaded Ministra item can be stored before its series marker is
|
||||
* known. The retry drops the id (`/movie/<tv id>` resolves to an
|
||||
* unrelated film), so a wrong default costs one negatively-cached search
|
||||
* rather than another title's metadata.
|
||||
*
|
||||
* `'tv'` gets no such retry. It is only ever reached on positive
|
||||
* evidence — a series category, an `is_series` flag, or a non-empty
|
||||
* episode array — and retrying it as a movie would trade that evidence
|
||||
* for a same-title film: the gate cannot tell an adaptation sharing its
|
||||
* show's name and year from the show itself.
|
||||
*/
|
||||
private buildAttempts(item: DashboardHeroTmdbItem): HeroTmdbAttempt[] {
|
||||
if (item.type !== 'movie' && item.type !== 'series') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hints = item.stalker_item
|
||||
? extractStalkerItemTmdbHints(item.stalker_item)
|
||||
: null;
|
||||
const title = hints?.title ?? item.title;
|
||||
// A stalker entry with no usable name falls back to the placeholder
|
||||
// `extractStalkerItemTitle` produces. The detail view refuses to
|
||||
// enrich those, and so must this — "Unknown" is itself a real film
|
||||
// title (2011), so searching for it attaches another movie's data.
|
||||
if (!title || (hints !== null && !hints.title && title === 'Unknown')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const mediaType: TmdbMediaType =
|
||||
hints?.mediaType ?? (item.type === 'series' ? 'tv' : 'movie');
|
||||
const year = hints?.year ?? extractYear(null, title);
|
||||
const primary: HeroTmdbAttempt = {
|
||||
mediaType,
|
||||
title,
|
||||
originalTitle: hints?.originalTitle,
|
||||
tmdbId: hints?.tmdbId,
|
||||
year,
|
||||
};
|
||||
|
||||
return mediaType === 'movie'
|
||||
? [primary, { ...primary, mediaType: 'tv', tmdbId: undefined }]
|
||||
: [primary];
|
||||
}
|
||||
|
||||
private async loadExtras(
|
||||
item: Pick<GlobalRecentItem, 'title' | 'type'>
|
||||
attempts: readonly HeroTmdbAttempt[]
|
||||
): Promise<DashboardHeroTmdbExtras | null> {
|
||||
try {
|
||||
const query = {
|
||||
title: item.title,
|
||||
year: extractYear(null, item.title),
|
||||
};
|
||||
let details =
|
||||
item.type === 'movie'
|
||||
? await this.enrichment.enrichMovie(query)
|
||||
: await this.enrichment.enrichTv(query);
|
||||
if (!details && item.type === 'movie') {
|
||||
// Stalker embedded-series items (vclub) are typed 'movie'
|
||||
// in activity rows but are TV shows on TMDB — the detail
|
||||
// view resolves them via is_series, so the TV lookup is
|
||||
// usually already cached. Misses are negative-cached, so
|
||||
// the retry never turns into repeated network traffic.
|
||||
details = await this.enrichment.enrichTv(query);
|
||||
}
|
||||
if (!details) {
|
||||
return null;
|
||||
for (const attempt of attempts) {
|
||||
const query = {
|
||||
title: attempt.title,
|
||||
originalTitle: attempt.originalTitle,
|
||||
tmdbId: attempt.tmdbId,
|
||||
year: attempt.year,
|
||||
};
|
||||
const details =
|
||||
attempt.mediaType === 'tv'
|
||||
? await this.enrichment.enrichTv(query)
|
||||
: await this.enrichment.enrichMovie(query);
|
||||
if (details) {
|
||||
return toHeroExtras(details);
|
||||
}
|
||||
}
|
||||
|
||||
const rating =
|
||||
(details.vote_count ?? 0) > 0 && details.vote_average
|
||||
? details.vote_average.toFixed(1)
|
||||
: null;
|
||||
const genres = (details.genres ?? [])
|
||||
.map((genre) => genre.name)
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_HERO_GENRES);
|
||||
|
||||
return {
|
||||
backdropUrl: tmdbBackdropUrl(details.backdrop_path),
|
||||
rating,
|
||||
genres,
|
||||
};
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn('Dashboard hero TMDB extras failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toHeroExtras(details: {
|
||||
backdrop_path?: string | null;
|
||||
vote_average?: number;
|
||||
vote_count?: number;
|
||||
genres?: { name?: string }[];
|
||||
}): DashboardHeroTmdbExtras {
|
||||
const rating =
|
||||
(details.vote_count ?? 0) > 0 && details.vote_average
|
||||
? details.vote_average.toFixed(1)
|
||||
: null;
|
||||
|
||||
return {
|
||||
backdropUrl: tmdbBackdropUrl(details.backdrop_path),
|
||||
rating,
|
||||
genres: (details.genres ?? [])
|
||||
.map((genre) => genre.name)
|
||||
.filter((name): name is string => Boolean(name))
|
||||
.slice(0, MAX_HERO_GENRES),
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -599,7 +599,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
}
|
||||
|
||||
private heroTmdbKey(item: GlobalRecentItem): string {
|
||||
return `${item.type}:${item.title}`;
|
||||
return this.heroTmdb.keyFor(item);
|
||||
}
|
||||
|
||||
private toTrendingCard(item: DashboardTrendingItem): DashboardRailCard {
|
||||
|
||||
Reference in new issue
Block a user