Merge remote-tracking branch 'origin/master' into fix/workspace-route-swap

# Conflicts:
#	apps/web/src/app/app.config.ts
This commit is contained in:
4gray committed 2026-10-13 21:55:08 +02:00
commit 3a68793df2
78 files changed
+3051 -218

No files matched your search

@@ -20,6 +20,7 @@ import {
ProviderImportKind,
SOURCE_TYPE_ICONS,
} from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
interface CandidateSummaryRow {
labelKey: string;
@@ -145,6 +146,7 @@ function maskUrlQueryPasswords(url: string): string {
*/
@Component({
selector: 'app-auto-import',
providers: [provideFormFieldDefaults()],
templateUrl: './auto-import.component.html',
styleUrl: './auto-import.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -50,6 +50,7 @@ import {
toStalkerImportFeedback,
toStalkerPlaylistIdentityFields,
} from './stalker-import-identity';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
/**
* A MAC and the device IDs that belong to exactly it. Kept together because
@@ -75,6 +76,7 @@ interface StalkerSettledIdentity {
TranslatePipe,
],
selector: 'app-stalker-portal-import',
providers: [provideFormFieldDefaults()],
templateUrl: './stalker-portal-import.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
@@ -12,9 +12,11 @@ import {
} from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-text-import',
providers: [provideFormFieldDefaults()],
templateUrl: './text-import.component.html',
styleUrls: ['./text-import.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -15,9 +15,11 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-url-upload',
providers: [provideFormFieldDefaults()],
templateUrl: './url-upload.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
@@ -228,6 +228,26 @@ describe('XtreamCodeImportComponent form', () => {
);
}
it('outlines its fields with a dynamic subscript without a root provider', () => {
// The root injector no longer provides MAT_FORM_FIELD_DEFAULT_OPTIONS
// (it pulled Material's form field and @angular/forms into startup);
// the form carries provideFormFieldDefaults() itself.
const { root } = render();
const fields = Array.from(root.querySelectorAll('mat-form-field'));
expect(fields.length).toBeGreaterThan(0);
for (const field of fields) {
expect(field.classList).toContain(
'mat-form-field-appearance-outline'
);
expect(
field.querySelector(
'.mat-mdc-form-field-subscript-dynamic-size'
)
).not.toBeNull();
}
});
it('labels the name field like the other add-source forms', () => {
const { root } = render();
@@ -30,6 +30,7 @@ import {
normalizeXtreamServerUrl,
Playlist,
} from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
function xtreamServerUrlValidator(
control: AbstractControl
@@ -59,6 +60,7 @@ function xtreamServerUrlValidator(
TranslatePipe,
],
selector: 'app-xtream-code-import',
providers: [provideFormFieldDefaults()],
templateUrl: './xtream-code-import.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
@@ -62,6 +62,7 @@ import {
STALKER_PLAYLIST_CONNECTION_EDITOR,
STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS,
} from './stalker-playlist-connection-editor.token';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
type DesktopFileSaveBridge = Pick<
typeof window.electron,
@@ -165,7 +166,7 @@ type DesktopFileSaveBridge = Pick<
}
`,
],
providers: [DatePipe],
providers: [provideFormFieldDefaults(), DatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ClipboardModule,
@@ -26,6 +26,24 @@ describe('grid list rating helpers', () => {
).toBe('7.2');
});
it('shows no badge for a provider stand-in or an unrated zero', () => {
for (const value of ['N/A', 'n/a', '-', '0', '0.0', 0]) {
expect(formatGridRating(value)).toBeUndefined();
}
});
it('falls back to the generic rating when imdb rating is a stand-in', () => {
expect(
resolveGridRating({
rating: '6.5',
rating_imdb: 'N/A',
})
).toBe('6.5');
expect(resolveGridRating({ rating: '0', rating_imdb: '-' })).toBe(
undefined
);
});
it('falls back to the generic rating when imdb rating is blank', () => {
expect(
resolveGridRating({
@@ -14,6 +14,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import { readProviderRating } from '@iptvnator/shared/interfaces';
import {
PortalWatchState,
getXtreamCatchupDays,
@@ -49,22 +50,19 @@ interface GridListItem {
[key: string]: unknown;
}
/**
* Card rating badge text. Provider stand-ins (`"N/A"`, `"-"`) and an unrated
* zero read as no rating — see `readProviderRating` — so the badge is hidden
* and {@link resolveGridRating} can reach the generic `rating` behind them.
*/
export function formatGridRating(value: unknown): string | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toFixed(1);
}
if (typeof value !== 'string') {
const text = readProviderRating(value);
if (text === undefined) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const numericRating = Number.parseFloat(trimmed);
return Number.isFinite(numericRating) ? numericRating.toFixed(1) : trimmed;
const numericRating = Number.parseFloat(text);
return Number.isFinite(numericRating) ? numericRating.toFixed(1) : text;
}
export function resolveGridRating(
@@ -253,44 +253,71 @@ describe('stalker-vod.utils regressions', () => {
});
it('preserves TMDB enrichment fields through info re-normalization', () => {
const tmdbCast = [{ name: 'Karl Urban', profileUrl: null }];
// Detail views re-normalize the selected item on every render, so
// every field `mergeStalkerInfoWithTmdb` can set has to survive here
const tmdbCast = [{ name: 'Rowan Quill', profileUrl: null }];
const tmdbRecommendations = [
{ tmdbId: 1, title: 'Invincible', year: 2021, posterUrl: null },
{ tmdbId: 1, title: 'Harbor Static', year: 2021, posterUrl: null },
];
const tmdbGenres = [{ id: 18, name: 'Drama' }];
const tmdbCountries = [{ code: 'NO', name: 'Norway' }];
const info = createStalkerInfo({
id: '7',
info: {
name: 'The Boys s05',
name: 'Lantern Coast s05',
movie_image: 'http://portal/poster.jpg',
description: 'Plot',
actors: 'Karl Urban',
actors: 'Rowan Quill',
director: '',
releasedate: '2026',
genre: 'Action',
releasedate: '2026-03-01',
genre: 'Drama',
rating_imdb: '',
rating_kinopoisk: '8.1',
tmdb_id: 4242,
tmdb_cast: tmdbCast,
tmdb_directors: [
{
name: 'Eric Kripke',
name: 'Mara Tollen',
profileUrl: null,
tmdbPersonId: 1216630,
},
],
tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/boys.jpg',
tmdb_status: 'returning',
tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/coast.jpg',
tmdb_trailer: 'abc123def',
tmdb_recommendations: tmdbRecommendations,
tmdb_genres: tmdbGenres,
tmdb_countries: tmdbCountries,
tmdb_media_type: 'tv',
tmdb_supplied_release_date: true,
},
});
expect(info.tmdb_id).toBe(4242);
expect(info.tmdb_cast).toEqual(tmdbCast);
expect(info.tmdb_directors?.[0]?.name).toBe('Eric Kripke');
expect(info.tmdb_directors?.[0]?.name).toBe('Mara Tollen');
expect(info.tmdb_status).toBe('returning');
expect(info.tmdb_backdrop).toBe(
'https://image.tmdb.org/t/p/w1280/boys.jpg'
'https://image.tmdb.org/t/p/w1280/coast.jpg'
);
expect(info.tmdb_trailer).toBe('abc123def');
expect(info.tmdb_recommendations).toEqual(tmdbRecommendations);
expect(info.tmdb_genres).toEqual(tmdbGenres);
expect(info.tmdb_countries).toEqual(tmdbCountries);
expect(info.tmdb_media_type).toBe('tv');
expect(info.tmdb_supplied_release_date).toBe(true);
});
it('adds no TMDB fields to a row that was never enriched', () => {
const info = createStalkerInfo({
id: '8',
info: { name: 'Unenriched Title', releasedate: '2019' },
});
expect(
Object.keys(info).filter((key) => key.startsWith('tmdb_'))
).toEqual([]);
});
describe('temporary-link flags survive normalization', () => {
@@ -342,6 +369,82 @@ describe('stalker-vod.utils regressions', () => {
});
});
describe('provider placeholders in Stalker VOD info', () => {
// The series view renders `info` directly and the shared VOD template
// renders it through the view-model adapter, so the stand-ins have to
// be gone from `info` itself — not just from one template's guards.
const PLACEHOLDER_ROW = {
id: '31',
cmd: '/media/file_31.mpg',
name: 'Placeholder Movie',
o_name: 'N/A',
screenshot_uri: 'N/A',
description: 'N/A',
actors: 'n/a',
director: '-',
year: 'N/A',
genres_str: ' N/A ',
rating_imdb: 'N/A',
rating_kinopoisk: '0',
};
it('maps every stand-in to the empty value the info shape uses for absent', () => {
const info = createStalkerInfo(PLACEHOLDER_ROW);
expect(info).toEqual({
name: 'Placeholder Movie',
o_name: undefined,
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
});
});
it('keeps the series view free of stand-ins through re-normalization', () => {
const displayed = normalizeStalkerVodDetailsItem(
normalizeStalkerVodDetailsItem({
...PLACEHOLDER_ROW,
is_series: 1,
})
);
expect(displayed.info.releasedate).toBe('');
expect(displayed.info.rating_imdb).toBe('');
expect(displayed.info.rating_kinopoisk).toBe('');
expect(displayed.info.description).toBe('');
});
it('falls through a placeholder to the usable value behind it', () => {
const info = createStalkerInfo({
id: '32',
year: '2011',
genres_str: 'Drama',
description: 'Plot from the catalog row',
rating_imdb: 7.1,
info: {
name: 'N/A',
o_name: 'Original Title',
releasedate: 'N/A',
genre: '-',
description: 'N/A',
rating_imdb: '0.0',
},
});
expect(info.name).toBe('Original Title');
expect(info.o_name).toBe('Original Title');
expect(info.releasedate).toBe('2011');
expect(info.genre).toBe('Drama');
expect(info.description).toBe('Plot from the catalog row');
expect(info.rating_imdb).toBe('7.1');
});
});
describe('firstNonBlankStalkerId', () => {
it('skips a blank candidate instead of treating it as set', () => {
expect(firstNonBlankStalkerId('', ' ', '77')).toBe('77');
@@ -4,8 +4,10 @@ import { Observable } from 'rxjs';
import { PlaylistsService } from '@iptvnator/services';
import {
createStalkerVodItem,
firstProviderText,
isStalkerSeriesFlag,
isStalkerSeriesItem,
readProviderRating,
StalkerVodDetails,
StalkerVodInfo,
VodDetailsItem,
@@ -146,50 +148,86 @@ function toStringOrFallback(value: unknown, fallback = ''): string {
return String(value);
}
function toOptionalString(value: unknown): string | undefined {
if (value === null || value === undefined || value === '') {
return undefined;
}
return String(value);
}
/**
* Maps a raw portal row (or an already-built selection, on re-normalization)
* to the `info` both Stalker detail templates render.
*
* Portals send `"N/A"`, `"-"` or an unrated `"0"` score where they have
* nothing, so every field reads the first USABLE candidate and falls back to
* `''`, which is how this shape spells "absent". The series view binds
* `info` directly, and TMDB enrichment keeps any non-empty provider rating
* and release date, so a stand-in surviving this point would be rendered
* AND would block the TMDB value that should have replaced it.
*/
export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
const info = item.info ?? {};
const text = (...values: unknown[]) => firstProviderText(...values) ?? '';
const rating = (primary: unknown, fallback: unknown) =>
readProviderRating(primary) ?? readProviderRating(fallback) ?? '';
return {
name: toStringOrFallback(
info.name ?? info.o_name ?? item.o_name ?? item.name ?? item.title,
'Unknown'
name:
firstProviderText(
info.name,
info.o_name,
item.o_name,
item.name,
item.title
) ?? 'Unknown',
o_name: firstProviderText(info.o_name, item.o_name),
movie_image: text(
info.movie_image,
item.cover,
item.screenshot_uri,
item.logo
),
o_name: toOptionalString(info.o_name ?? item.o_name),
movie_image: toStringOrFallback(
info.movie_image ?? item.cover ?? item.screenshot_uri ?? item.logo
),
description: toStringOrFallback(info.description ?? item.description),
actors: toStringOrFallback(info.actors ?? item.actors),
director: toStringOrFallback(info.director ?? item.director),
releasedate: toStringOrFallback(
info.releasedate ?? item.releasedate ?? item.year
),
genre: toStringOrFallback(info.genre ?? item.genre ?? item.genres_str),
rating_imdb: toStringOrFallback(info.rating_imdb ?? item.rating_imdb),
rating_kinopoisk: toStringOrFallback(
info.rating_kinopoisk ?? item.rating_kinopoisk
),
// TMDB enrichment fields must survive re-normalization — detail
// views normalize the selected item on every render
...(info.tmdb_id ? { tmdb_id: info.tmdb_id } : {}),
...(info.tmdb_cast ? { tmdb_cast: info.tmdb_cast } : {}),
...(info.tmdb_directors ? { tmdb_directors: info.tmdb_directors } : {}),
...(info.tmdb_status ? { tmdb_status: info.tmdb_status } : {}),
...(info.tmdb_backdrop ? { tmdb_backdrop: info.tmdb_backdrop } : {}),
...(info.tmdb_trailer ? { tmdb_trailer: info.tmdb_trailer } : {}),
...(info.tmdb_recommendations
? { tmdb_recommendations: info.tmdb_recommendations }
: {}),
description: text(info.description, item.description),
actors: text(info.actors, item.actors),
director: text(info.director, item.director),
releasedate: text(info.releasedate, item.releasedate, item.year),
genre: text(info.genre, item.genre, item.genres_str),
rating_imdb: rating(info.rating_imdb, item.rating_imdb),
rating_kinopoisk: rating(info.rating_kinopoisk, item.rating_kinopoisk),
...pickTmdbEnrichment(info),
};
}
type TmdbEnrichmentKey = Extract<keyof StalkerVodInfo, `tmdb_${string}`>;
/**
* Every TMDB enrichment field of {@link StalkerVodInfo}. A `Record` over the
* `tmdb_*` keys, so adding one to the interface fails to compile until it is
* listed here: detail views re-normalize the selected item on every render,
* and a field this list missed would vanish right after enrichment.
*/
const TMDB_ENRICHMENT_KEYS: Record<TmdbEnrichmentKey, true> = {
tmdb_id: true,
tmdb_cast: true,
tmdb_directors: true,
tmdb_status: true,
tmdb_backdrop: true,
tmdb_trailer: true,
tmdb_recommendations: true,
tmdb_genres: true,
tmdb_countries: true,
tmdb_media_type: true,
tmdb_supplied_release_date: true,
};
function pickTmdbEnrichment(
info: Partial<StalkerVodInfo>
): Partial<Pick<StalkerVodInfo, TmdbEnrichmentKey>> {
const picked: Record<string, unknown> = {};
for (const key of Object.keys(
TMDB_ENRICHMENT_KEYS
) as TmdbEnrichmentKey[]) {
if (info[key]) {
picked[key] = info[key];
}
}
return picked;
}
/**
* Narrows a raw portal row to the fields the detail/playback flows need.
*
@@ -0,0 +1,64 @@
import { ComponentFixture } from '@angular/core/testing';
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
import { createStalkerSeriesViewHarness } from './stalker-series-view.test-harness';
/**
* The view re-normalizes the selected item on every render, so the TMDB
* genre and country facets the enrichment merged into the store item have
* to survive that pass to become clickable Discover chips.
*/
describe('StalkerSeriesViewComponent TMDB facet chips', () => {
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
const { selectedItem, createFixture } = createStalkerSeriesViewHarness(() =>
jest.fn()
);
async function stabilize(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
}
function facetChipLabels(): string[] {
return Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll(
'button.meta-chip__facet'
)
).map((chip) => chip.textContent?.trim() ?? '');
}
beforeEach(async () => {
fixture = await createFixture();
});
afterEach(() => {
fixture?.destroy();
});
it('renders the enriched genres and countries as Discover chips', async () => {
await stabilize();
// Enrichment patches the store item after selection
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
description: 'Series description',
movie_image: 'poster.jpg',
genre: 'Drama',
tmdb_id: 777,
tmdb_genres: [
{ id: 18, name: 'Drama' },
{ id: 9648, name: 'Mystery' },
],
tmdb_countries: [{ code: 'NO', name: 'Norway' }],
tmdb_media_type: 'tv',
},
} as never);
await stabilize();
expect(facetChipLabels()).toEqual(
expect.arrayContaining(['Drama', 'Mystery', 'Norway'])
);
});
});
@@ -6,13 +6,16 @@ import {
normalizeFavoriteStorage,
normalizeRecentStorage,
} from './pwa-collection-identity';
import type { UserDataStorage } from '@iptvnator/services';
import {
XTREAM_USER_DATA_KEYS,
type UserDataStorage,
} from '@iptvnator/services';
import { XtreamContentItem } from './xtream-data-source.interface';
const STORAGE_KEYS = {
COLLECTION_ITEMS: 'xtream-collection-items',
FAVORITES: 'xtream-favorites',
RECENT_ITEMS: 'xtream-recent-items',
COLLECTION_ITEMS: XTREAM_USER_DATA_KEYS.collectionItems,
FAVORITES: XTREAM_USER_DATA_KEYS.favorites,
RECENT_ITEMS: XTREAM_USER_DATA_KEYS.recentItems,
};
/** Synchronous persistence and migration using only snapshots and cached identity evidence. */
@@ -342,6 +342,64 @@ describe('PWA Xtream collection identity', () => {
).toEqual(types);
});
it('restores playback positions under the playlist they are restored to', async () => {
await source.restoreUserData('p1', {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [
{
contentXtreamId: 42,
contentType: 'vod',
positionSeconds: 90,
playlistId: 'exported-id',
updatedAt: '2026-10-01T12:00:00.000Z',
},
],
});
expect(await source.getPlaybackPosition('p1', 42, 'vod')).toEqual({
contentXtreamId: 42,
contentType: 'vod',
positionSeconds: 90,
playlistId: 'p1',
updatedAt: '2026-10-01T12:00:00.000Z',
});
});
it('keeps the artwork saved with each view through clear/restore', async () => {
await source.restoreUserData('p1', {
hiddenCategories: [],
playbackPositions: [],
favorites: [],
recentlyViewed: [
{
contentType: 'movie',
xtreamId: 42,
viewedAt: '2026-10-01T12:00:00.000Z',
backdropUrl: 'https://img.example.com/still.jpg',
},
],
});
expect(read('xtream-recent-items').p1).toEqual([
{
id: 'movie:42',
viewedAt: '2026-10-01T12:00:00.000Z',
backdropUrl: 'https://img.example.com/still.jpg',
},
]);
const backup = await source.clearPlaylistContent('p1');
expect(backup.recentlyViewed).toEqual([
{
contentType: 'movie',
xtreamId: 42,
viewedAt: '2026-10-01T12:00:00.000Z',
backdropUrl: 'https://img.example.com/still.jpg',
},
]);
});
it('preserves ambiguous numeric legacy entries without selecting the last matching type', async () => {
localStorage.setItem(
'xtream-favorites',
@@ -22,6 +22,7 @@ import {
ParentalLockService,
PlaylistsService,
USER_DATA_STORAGE,
XTREAM_USER_DATA_KEYS,
} from '@iptvnator/services';
import { firstValueFrom } from 'rxjs';
import {
@@ -42,6 +43,7 @@ import {
typedCollectionRef,
itemCollectionKey,
findCollectionItems,
normalizeStoredBackdrop,
} from './pwa-collection-identity';
import { PwaCollectionStorage } from './pwa-collection-storage';
@@ -50,7 +52,7 @@ import { PwaCollectionStorage } from './pwa-collection-storage';
*/
const STORAGE_KEYS = {
PLAYLISTS: 'xtream-playlists',
PLAYBACK_POSITIONS: 'xtream-playback-positions',
PLAYBACK_POSITIONS: XTREAM_USER_DATA_KEYS.playbackPositions,
};
interface XtreamCachedContentItem {
@@ -1356,6 +1358,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return {
...identity,
viewedAt: item.viewedAt,
...normalizeStoredBackdrop(item),
};
})
.filter(
@@ -1403,16 +1406,22 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
type: item.contentType,
});
if (id !== null)
recentItems[playlistId].push({ id, viewedAt: item.viewedAt });
recentItems[playlistId].push({
id,
viewedAt: item.viewedAt,
...normalizeStoredBackdrop(item),
});
}
this.collectionStorage.saveRecentItemsToStorage(recentItems);
// Restore playback positions
// Restore playback positions under this playlist: an archive's carry
// the id they were exported with, and the player matches on it.
this.clearPlaybackPositionsForPlaylist(playlistId);
const playbackPositions = this.getPlaybackPositionsFromStorage();
playbackPositions[playlistId] = restoreState.playbackPositions.map(
(position) => ({
...position,
playlistId,
updatedAt: position.updatedAt ?? new Date().toISOString(),
})
);
@@ -0,0 +1,99 @@
import { XtreamSerieInfo, XtreamVodInfo } from '@iptvnator/shared/interfaces';
import {
stripXtreamSerieInfoPlaceholders,
stripXtreamVodInfoPlaceholders,
} from './xtream-detail-metadata';
describe('Xtream detail placeholders', () => {
it('empties every stand-in the VOD detail route would render', () => {
const info = stripXtreamVodInfoPlaceholders({
name: 'Placeholder Film',
o_name: 'N/A',
movie_image: 'N/A',
cover_big: '-',
releasedate: 'N/A',
genre: 'n/a',
duration: 'N/A',
country: '-',
director: 'N/A',
actors: 'N/A',
cast: '-',
description: 'N/A',
plot: ' N/A ',
rating_imdb: 'N/A',
rating_kinopoisk: '0',
backdrop_path: ['N/A', 'https://img.example/backdrop.jpg'],
duration_secs: 0,
} as unknown as XtreamVodInfo);
expect(info).toEqual({
name: 'Placeholder Film',
o_name: '',
movie_image: '',
cover_big: '',
releasedate: '',
genre: '',
duration: '',
country: '',
director: '',
actors: '',
cast: '',
description: '',
plot: '',
rating_imdb: '',
rating_kinopoisk: '',
backdrop_path: ['https://img.example/backdrop.jpg'],
duration_secs: 0,
});
});
it('leaves real values, absent fields and non-text fields untouched', () => {
const source = {
name: 'Real Film',
releasedate: '2019-04-05',
rating_imdb: '7.4',
rating: 0,
tmdb_id: 123,
} as unknown as XtreamVodInfo;
expect(stripXtreamVodInfoPlaceholders(source)).toEqual(source);
});
it('empties every stand-in the series detail route would render', () => {
const info = stripXtreamSerieInfoPlaceholders({
name: 'Placeholder Series',
cover: 'N/A',
plot: 'N/A',
cast: '-',
director: 'n/a',
genre: 'N/A',
releaseDate: 'N/A',
rating: '0',
rating_5based: 0,
episode_run_time: 'N/A',
backdrop_path: ['-'],
} as unknown as XtreamSerieInfo);
expect(info).toEqual({
name: 'Placeholder Series',
cover: '',
plot: '',
cast: '',
director: '',
genre: '',
releaseDate: '',
rating: '',
rating_5based: 0,
episode_run_time: '',
backdrop_path: [],
});
});
it('passes a malformed info payload through unchanged', () => {
expect(stripXtreamSerieInfoPlaceholders(undefined as never)).toBe(
undefined
);
const list = [] as unknown as XtreamSerieInfo;
expect(stripXtreamSerieInfoPlaceholders(list)).toBe(list);
});
});
@@ -0,0 +1,98 @@
import {
readProviderRating,
readProviderText,
XtreamSerieInfo,
XtreamVodInfo,
} from '@iptvnator/shared/interfaces';
const VOD_TEXT_FIELDS = [
'name',
'o_name',
'movie_image',
'cover_big',
'releasedate',
'genre',
'duration',
'country',
'director',
'actors',
'cast',
'description',
'plot',
] as const satisfies readonly (keyof XtreamVodInfo)[];
const VOD_RATING_FIELDS = [
'rating_imdb',
'rating_kinopoisk',
] as const satisfies readonly (keyof XtreamVodInfo)[];
const SERIE_TEXT_FIELDS = [
'name',
'cover',
'plot',
'cast',
'director',
'genre',
'releaseDate',
'episode_run_time',
] as const satisfies readonly (keyof XtreamSerieInfo)[];
const SERIE_RATING_FIELDS = [
'rating',
] as const satisfies readonly (keyof XtreamSerieInfo)[];
/**
* Removes provider stand-ins (`"N/A"`, `"-"`, an unrated `"0"` score) from
* an Xtream detail response before it becomes the store selection.
*
* The VOD and series detail routes bind these `info` fields directly, and
* TMDB enrichment keeps any non-empty provider rating and release date — a
* stand-in left here is both rendered and shields the TMDB value that should
* have replaced it. Only string fields that are present are rewritten, to
* `''` (how these shapes spell "absent"), so the response keeps its shape.
*/
export function stripXtreamVodInfoPlaceholders(
info: XtreamVodInfo
): XtreamVodInfo {
return stripPlaceholders(info, VOD_TEXT_FIELDS, VOD_RATING_FIELDS);
}
/** {@link stripXtreamVodInfoPlaceholders} for `get_series_info`. */
export function stripXtreamSerieInfoPlaceholders(
info: XtreamSerieInfo
): XtreamSerieInfo {
return stripPlaceholders(info, SERIE_TEXT_FIELDS, SERIE_RATING_FIELDS);
}
function stripPlaceholders<T extends { backdrop_path?: unknown }>(
info: T,
textFields: readonly (keyof T)[],
ratingFields: readonly (keyof T)[]
): T {
// Broken panels answer with `[]` or nothing; leave those to the callers'
// existing sparse-response handling
if (!info || typeof info !== 'object' || Array.isArray(info)) {
return info;
}
const stripped: Record<keyof T, unknown> = { ...info };
const rewrite = (
fields: readonly (keyof T)[],
read: (value: unknown) => string | undefined
) => {
for (const field of fields) {
const value = info[field];
if (typeof value === 'string') {
stripped[field] = read(value) ?? '';
}
}
};
rewrite(textFields, readProviderText);
rewrite(ratingFields, readProviderRating);
if (Array.isArray(info.backdrop_path)) {
stripped.backdrop_path = info.backdrop_path.filter(
(url) => readProviderText(url) !== undefined
);
}
return stripped as T;
}
@@ -28,6 +28,28 @@ describe('Xtream VOD selection helpers', () => {
);
});
it('strips provider stand-ins from the detail info it publishes', () => {
const selection = buildXtreamVodSelection(
{
info: {
name: 'Placeholder Film',
releasedate: 'N/A',
rating_imdb: 'N/A',
director: '-',
},
} as unknown as Parameters<typeof buildXtreamVodSelection>[0],
undefined,
42
);
expect(selection.info).toEqual({
name: 'Placeholder Film',
releasedate: '',
rating_imdb: '',
director: '',
});
});
it('keeps a complete detail playback pair ahead of a catalog collision', () => {
const catalogItem = {
stream_id: 42,
@@ -1,4 +1,5 @@
import {
getXtreamVodInfo,
XtreamVodDetails,
XtreamVodStream,
} from '@iptvnator/shared/interfaces';
@@ -6,6 +7,7 @@ import {
resolveXtreamVodPlaybackSource,
XtreamVodPlaybackItem,
} from '../services/xtream-vod-playback-source';
import { stripXtreamVodInfoPlaceholders } from './xtream-detail-metadata';
export type XtreamVodSelection = XtreamVodDetails & {
[key: string]: unknown;
@@ -35,11 +37,13 @@ export function buildXtreamVodSelection(
resolveXtreamVodPlaybackSource(
catalogItem as unknown as XtreamVodPlaybackItem
);
const info = getXtreamVodInfo(vodDetails);
return {
...catalogItem,
...recoveredCatalogItem,
...vodDetails,
...(info ? { info: stripXtreamVodInfoPlaceholders(info) } : {}),
stream_id: playbackSource?.streamId ?? Number(vodId),
container_extension: playbackSource?.containerExtension ?? null,
xtream_id:
@@ -345,6 +345,39 @@ describe('XtreamStore VOD details recovery', () => {
expect(store.isLoadingDetails()).toBe(false);
});
it('publishes series details without provider stand-ins', async () => {
const xtreamApiService = {
getSeriesInfo: jest.fn().mockResolvedValue({
info: {
name: 'Placeholder Series',
plot: 'N/A',
rating: '0',
releaseDate: 'N/A',
},
seasons: [],
episodes: {},
}),
getVodInfo: jest.fn(),
getVodStream: jest.fn(),
};
const store = configureStore(xtreamApiService);
store.fetchSerialDetailsWithMetadata({
categoryId: 7,
serialId: '99',
});
await waitForCondition(() => store.selectedItem() !== null);
expect(
(store.selectedItem() as { info: Record<string, unknown> }).info
).toEqual({
name: 'Placeholder Series',
plot: '',
rating: '',
releaseDate: '',
});
});
it('clears the previous series while a reused-route request is pending or fails', async () => {
const nextDetails = createDeferred<never>();
const xtreamApiService = {
@@ -35,6 +35,7 @@ import {
createXtreamDetailsRequestGuard,
resolveXtreamVodDetailsSelection,
} from './xtream-details-request';
import { stripXtreamSerieInfoPlaceholders } from './xtream-detail-metadata';
/** Facade composing the Xtream feature stores and cross-feature workflows. */
export const XtreamStore = signalStore(
@@ -255,6 +256,9 @@ export const XtreamStore = signalStore(
store.setSelectedCategory(params.categoryId);
store.setSelectedItem({
...serialDetails,
info: stripXtreamSerieInfoPlaceholders(
serialDetails?.info
),
series_id: params.serialId,
});
store.resetTmdbSeasonMetadata(params.serialId);
@@ -1,7 +1,7 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { BehaviorSubject, of } from 'rxjs';
import { DatabaseService } from '@iptvnator/services';
import { SearchResultsComponent } from './search-results.component';
import {
@@ -54,6 +54,9 @@ class MockXtreamStore {
readonly searchFilters = signal(DEFAULT_SEARCH_FILTERS);
readonly searchResults = signal<XtreamContentItem[]>([]);
readonly isSearching = signal(false);
readonly isLoadingContent = signal(false);
readonly isLoadingCategories = signal(false);
readonly isImporting = signal(false);
setSearchTerm = jest.fn((term: string) => {
this.searchTerm.set(term);
@@ -377,7 +380,7 @@ describe('SearchResultsComponent global search', () => {
it('refuses to append pages while an edited query is still debouncing', async () => {
// Regression: the auto-fill can request more results right after the
// reset identity changes, i.e. inside the 300ms search debounce. An
// reset identity changes, before the search effect has run. An
// append with the edited term would fetch a page of the NEW query at
// the OLD offset and interleave it into the old query's results.
const firstPage = Array.from({ length: 101 }, (_, index) =>
@@ -402,7 +405,7 @@ describe('SearchResultsComponent global search', () => {
databaseService.globalSearchContent.mockClear();
// The user edits the query; the fresh offset-zero search has not run
// yet (debounce pending), so appending must be refused entirely.
// yet (effects not flushed), so appending must be refused entirely.
store.searchTerm.set('matrix reloaded');
await component.loadMoreGlobalResults();
@@ -506,3 +509,190 @@ describe('SearchResultsComponent in-portal result window', () => {
}
});
});
describe('SearchResultsComponent search trigger', () => {
let queryParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let globalSearchContent: jest.Mock;
function setUp(initialQuery: string): void {
queryParams = new BehaviorSubject(
convertToParamMap({ q: initialQuery })
);
globalSearchContent = jest.fn().mockResolvedValue([]);
TestBed.configureTestingModule({
providers: [
{ provide: XtreamStore, useClass: MockXtreamStore },
{ provide: Router, useValue: { navigate: jest.fn() } },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: { isGlobalSearch: true },
queryParamMap: queryParams.value,
},
queryParamMap: queryParams.asObservable(),
},
},
{
provide: DatabaseService,
useValue: { globalSearchContent },
},
],
});
}
function searchedTerms(): string[] {
return globalSearchContent.mock.calls.map(([term]) => term as string);
}
afterEach(() => {
jest.useRealTimers();
});
it('queries an applied term at once, without a second debounce', () => {
// The shell already debounces typing; a second timer here only added
// 300 ms to every search.
jest.useFakeTimers();
setUp('');
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
store.setSearchTerm('system');
TestBed.flushEffects();
expect(searchedTerms()).toEqual(['system']);
expect(jest.getTimerCount()).toBe(0);
});
it('queries only the route term when the store still holds an older one', () => {
setUp('system');
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
store.searchTerm.set('matrix');
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
expect(searchedTerms()).toEqual(['system']);
});
it('keeps a term applied ahead of the URL instead of re-querying the stale q', () => {
// The shell sets the store term first and writes `q` afterwards; the
// route sync must not snap the store back to the previous `q`.
setUp('syst');
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
globalSearchContent.mockClear();
store.setSearchTerm('system');
TestBed.flushEffects();
queryParams.next(convertToParamMap({ q: 'system' }));
TestBed.flushEffects();
expect(searchedTerms()).toEqual(['system']);
expect(store.searchTerm()).toBe('system');
});
it('ignores an older query that resolves after a newer term', async () => {
setUp('');
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
const older = createDeferred<GlobalSearchResult[]>();
const newer = createDeferred<GlobalSearchResult[]>();
globalSearchContent
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
store.setSearchTerm('syst');
TestBed.flushEffects();
store.setSearchTerm('system');
TestBed.flushEffects();
newer.resolve([
createSearchItem({ title: 'System' }) as GlobalSearchResult,
]);
await newer.promise;
older.resolve([
createSearchItem({ title: 'Syst' }) as GlobalSearchResult,
]);
await older.promise;
await Promise.resolve();
expect(searchedTerms()).toEqual(['syst', 'system']);
expect(store.searchResults().map((item) => item.title)).toEqual([
'System',
]);
});
});
describe('SearchResultsComponent in-portal search and catalog loading', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{ provide: XtreamStore, useClass: MockXtreamStore },
{ provide: Router, useValue: { navigate: jest.fn() } },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: {},
queryParamMap: convertToParamMap({ q: 'matrix' }),
},
queryParamMap: of(convertToParamMap({ q: 'matrix' })),
},
},
{
provide: DatabaseService,
useValue: {
globalSearchContent: jest.fn().mockResolvedValue([]),
},
},
],
});
});
function searchedTerms(store: MockXtreamStore): string[] {
return store.searchContent.mock.calls.map(
([params]) => (params as { term: string }).term
);
}
it('runs a prefilled search again once the catalog has loaded', () => {
// The in-portal search reads the loaded catalog, so a term applied
// while it loads would otherwise stay without results.
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
store.isLoadingContent.set(true);
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
expect(searchedTerms(store)).toEqual(['matrix']);
store.isLoadingContent.set(false);
TestBed.flushEffects();
expect(searchedTerms(store)).toEqual(['matrix', 'matrix']);
});
it('searches once when a new term and the loaded catalog arrive together', () => {
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
store.isLoadingContent.set(true);
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
store.searchContent.mockClear();
store.setSearchTerm('matrix reloaded');
store.isLoadingContent.set(false);
TestBed.flushEffects();
expect(searchedTerms(store)).toEqual(['matrix reloaded']);
});
it('searches once when the catalog is already loaded', () => {
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
expect(searchedTerms(store)).toEqual(['matrix']);
});
});
@@ -7,6 +7,7 @@ import {
inject,
linkedSignal,
signal,
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -121,6 +122,14 @@ export class SearchResultsComponent implements AfterViewInit {
readonly isGlobalSearch =
this.activatedRoute.snapshot.data?.['isGlobalSearch'] === true;
/** Nothing of the portal's catalog is loading or importing any more. */
private readonly isCatalogReady = computed(
() =>
!this.xtreamStore.isLoadingContent() &&
!this.xtreamStore.isLoadingCategories() &&
!this.xtreamStore.isImporting()
);
/** Whether to group global search results by playlist */
readonly groupByPlaylist = signal(
localStorage.getItem(SearchResultsComponent.GROUP_BY_STORAGE_KEY) !==
@@ -249,24 +258,48 @@ export class SearchResultsComponent implements AfterViewInit {
}
}
effect((onCleanup) => {
const term = this.searchTerm();
if (term.length >= this.minSearchLength) {
const timeout = setTimeout(() => this.executeSearch(), 300);
onCleanup(() => clearTimeout(timeout));
} else if (term.length < this.minSearchLength) {
this.clearResultsOnly();
}
});
// Created first so a route `q` reaches the store before the search
// effect's first run: a term left in the store by an earlier visit
// must not be queried on the way to the route's term.
effect(() => {
const queryTerm = this.routeSearchTerm();
if (queryTerm === this.searchTerm()) {
if (queryTerm === untracked(this.searchTerm)) {
return;
}
this.xtreamStore.setSearchTerm(queryTerm);
});
// One effect for both triggers, so a term and the end of catalog
// loading arriving in the same flush run one search, not two.
// - A new term runs at once: typed terms arrive already debounced by
// the workspace shell (SEARCH_INPUT_DEBOUNCE_MS), and a term from
// the URL or a filter change is a single deliberate change. A newer
// term supersedes an older query still in flight (request versions).
// - A prefilled in-portal search (a reload or deep link with `q`) can
// run while the portal's catalog is still loading, and the in-portal
// search reads that catalog: it runs again once loading has finished.
let previousTerm: string | undefined;
let wasCatalogReady: boolean | undefined;
effect(() => {
const term = this.searchTerm();
const ready = this.isCatalogReady();
const termChanged = term !== previousTerm;
const becameReady =
wasCatalogReady === false && ready && !this.isGlobalSearch;
previousTerm = term;
wasCatalogReady = ready;
if (!termChanged && !becameReady) {
return;
}
untracked(() => {
if (term.length >= this.minSearchLength) {
this.executeSearch();
} else if (termChanged) {
this.clearResultsOnly();
}
});
});
}
ngAfterViewInit() {
@@ -174,4 +174,37 @@ describe('vod-details-fallback.util', () => {
year: undefined,
});
});
describe('placeholder ratings', () => {
// Placeholder-only `info` is stripped upstream, which routes the
// movie here — the top-level catalog ratings must not resurface as
// "N/A" or a "0.0" score on this page instead.
const ratingOf = (
detail: Record<string, unknown>,
catalog: Record<string, unknown>
) =>
buildXtreamVodFallbackViewModel({
vodDetails: { info: [], ...detail } as XtreamVodDetails,
catalogItem: catalog as Partial<XtreamVodStream>,
vodId: 31,
})?.rating;
it('skips stand-ins and unrated zeros to the next usable rating', () => {
expect(
ratingOf(
{ rating_imdb: 'N/A', rating: 0 },
{ rating_imdb: '-', rating: '7.25' }
)
).toBe('7.25');
});
it('states no rating when every candidate is a stand-in', () => {
expect(
ratingOf(
{ rating_imdb: 'N/A', rating: '0' },
{ rating_imdb: '0.0', rating: 0 }
)
).toBeUndefined();
});
});
});
@@ -3,6 +3,7 @@ import {
XtreamVodInfo,
XtreamVodStream,
getXtreamVodInfo,
readProviderRating,
} from '@iptvnator/shared/interfaces';
type XtreamCategoryContext = {
@@ -117,9 +118,9 @@ export function buildXtreamVodFallbackViewModel(
sanitizeText(info?.cover_big) ??
undefined;
const rating =
sanitizeText(detailItem?.rating_imdb) ??
formatRating(detailItem?.rating_imdb) ??
formatRating(detailItem?.rating) ??
sanitizeText(catalogItem?.rating_imdb) ??
formatRating(catalogItem?.rating_imdb) ??
formatRating(catalogItem?.rating);
const added = formatUnixDate(
movieData?.added ??
@@ -200,16 +201,16 @@ function numericValue(value: unknown): number | undefined {
return undefined;
}
/**
* Provider stand-ins and an unrated zero read as no rating, so the next
* candidate is reached — see `readProviderRating`.
*/
function formatRating(value: unknown): string | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toFixed(1);
const text = readProviderRating(value);
if (text === undefined) {
return undefined;
}
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
return undefined;
return typeof value === 'number' ? value.toFixed(1) : text;
}
function extractYear(value: string | undefined): string | undefined {
@@ -0,0 +1,669 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import {
Playlist,
PlaylistBackupManifestV1,
XtreamPlaylistBackupEntry,
} from '@iptvnator/shared/interfaces';
import {
createPlaylistBackupService,
createPwaXtreamBackupState,
} from './playlist-backup.service.test-helpers';
import { existingXtreamPlaylist } from './playlist-backup.xtream-fixtures';
import { PwaXtreamBackupStateService } from './pwa-xtream-backup-state.service';
import {
USER_DATA_STORAGE,
UserDataStorage,
XTREAM_USER_DATA_KEYS,
} from './user-data-storage';
/**
* The browser build keeps Xtream favorites, history, positions and title
* snapshots in USER_DATA_STORAGE rather than SQLite. These specs run the
* backup with no Electron bridge, against the maps exactly as
* `PwaXtreamDataSource` writes them.
*/
describe('PlaylistBackupService Xtream user state in the PWA', () => {
const KEYS = XTREAM_USER_DATA_KEYS;
const otherPlaylist = {
...existingXtreamPlaylist,
_id: 'xtream-2',
title: 'Other Portal',
serverUrl: 'http://other.example.com',
username: 'other',
} as Playlist;
const heat = {
id: 501,
xtream_id: 501,
type: 'movie',
title: 'Heat',
poster_url: 'https://img.example.com/heat.jpg',
backdrop_url: 'https://img.example.com/heat-wide.jpg',
};
// Written under a bare id by builds that predate typed keys.
const legacyShow = { id: 77, xtream_id: 77, type: 'series', title: 'Show' };
const position = {
contentXtreamId: 501,
contentType: 'vod',
positionSeconds: 120,
durationSeconds: 3600,
playlistId: 'xtream-1',
updatedAt: '2026-07-05T20:00:00.000Z',
};
beforeEach(() => {
delete (window as unknown as { electron?: unknown }).electron;
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
TestBed.resetTestingModule();
});
function seed(storage: UserDataStorage = localStorage): void {
const write = (key: string, value: unknown) =>
storage.setItem(key, JSON.stringify(value));
write(KEYS.favorites, {
// 88 is a bare id no snapshot identifies: live, movie or series.
'xtream-1': ['movie:501', 77, 88],
'xtream-2': ['live:9'],
});
write(KEYS.recentItems, {
'xtream-1': [
{ id: 'live:601', viewedAt: '2026-07-04T18:30:00.000Z' },
{
id: 'movie:501',
viewedAt: '2026-07-01T10:00:00.000Z',
backdropUrl: 'https://img.example.com/heat-old.jpg',
},
{
id: 'movie:501',
viewedAt: '2026-07-03T10:00:00.000Z',
// Saved by an older build in snake_case.
backdrop_url: 'https://img.example.com/heat-still.jpg',
},
],
'xtream-2': [
{ id: 'live:9', viewedAt: '2026-07-02T10:00:00.000Z' },
],
});
write(KEYS.collectionItems, {
'xtream-1': {
'movie:501': heat,
'77': legacyShow,
'movie:999': { xtream_id: 999, type: 'movie', title: 'Gone' },
},
'xtream-2': { 'live:9': { xtream_id: 9, type: 'live' } },
});
write(KEYS.playbackPositions, {
'xtream-1': [position],
'xtream-2': [{ ...position, playlistId: 'xtream-2' }],
});
}
function read(key: string, storage: UserDataStorage = localStorage) {
return JSON.parse(storage.getItem(key) ?? 'null');
}
function pendingRestore(service: object) {
return (
service as unknown as {
pendingRestoreService: {
getOrThrow(playlistId: string): unknown;
set(playlistId: string, state: unknown): unknown;
};
}
).pendingRestoreService;
}
function xtreamOnly(manifest: PlaylistBackupManifestV1) {
return JSON.stringify({
...manifest,
playlists: [xtreamEntry(manifest)],
});
}
function playlistsService(playlists: Playlist[]) {
return {
getAllData: jest.fn(() => of(playlists)),
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
getRawPlaylistById: jest.fn(() => of('#EXTM3U')),
};
}
function xtreamEntry(
manifest: PlaylistBackupManifestV1,
exportedId = 'xtream-1'
): XtreamPlaylistBackupEntry {
return manifest.playlists.find(
(entry) => entry.exportedId === exportedId
) as XtreamPlaylistBackupEntry;
}
async function exportSeeded(): Promise<PlaylistBackupManifestV1> {
seed();
const service = createPlaylistBackupService({
playlistsService: playlistsService([
existingXtreamPlaylist,
otherPlaylist,
]),
});
return (await service.exportBackup()).manifest;
}
describe('export', () => {
it('writes each playlist its own favorites, history, positions and snapshots', async () => {
const manifest = await exportSeeded();
expect(xtreamEntry(manifest).userState).toEqual({
hiddenCategories: [],
favorites: [
{ contentType: 'movie', xtreamId: 501 },
{ contentType: 'series', xtreamId: 77 },
],
// One row per title, its newest view.
recentlyViewed: [
{
contentType: 'live',
xtreamId: 601,
viewedAt: '2026-07-04T18:30:00.000Z',
},
{
contentType: 'movie',
xtreamId: 501,
viewedAt: '2026-07-03T10:00:00.000Z',
// The history's own artwork, ahead of the snapshot's.
backdropUrl: 'https://img.example.com/heat-still.jpg',
},
],
playbackPositions: [position],
// Only what the lists reference, not every cached title.
collectionItems: [heat, legacyShow],
});
expect(xtreamEntry(manifest, 'xtream-2').userState).toEqual({
hiddenCategories: [],
favorites: [{ contentType: 'live', xtreamId: 9 }],
recentlyViewed: [
{
contentType: 'live',
xtreamId: 9,
viewedAt: '2026-07-02T10:00:00.000Z',
},
],
playbackPositions: [{ ...position, playlistId: 'xtream-2' }],
collectionItems: [{ xtream_id: 9, type: 'live' }],
});
// No pin store in the browser: absent, never an authoritative [].
expect(xtreamEntry(manifest).userState).not.toHaveProperty(
'sourcePins'
);
});
it('exports empty collections and no snapshots for a playlist with nothing stored', async () => {
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
const { manifest } = await service.exportBackup();
expect(xtreamEntry(manifest).userState).toEqual({
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
});
expect(xtreamEntry(manifest).userState).not.toHaveProperty(
'collectionItems'
);
});
it('exports a parked restore as it is instead of the stored lists', async () => {
seed();
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
const parked = {
hiddenCategories: [],
favorites: [{ contentType: 'series' as const, xtreamId: 3 }],
recentlyViewed: [],
playbackPositions: [],
};
pendingRestore(service).set('xtream-1', parked);
const { manifest } = await service.exportBackup();
expect(xtreamEntry(manifest).userState).toEqual(parked);
});
it.each([
['malformed JSON', '{"xtream-1": ['],
['a value that is not a map', '["movie:501"]'],
[
'a playlist entry that is not a list',
'{"xtream-1": "movie:501"}',
],
])('refuses to export when the favorites hold %s', async (_, raw) => {
seed();
localStorage.setItem(KEYS.favorites, raw);
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
await expect(service.exportBackup()).rejects.toThrow(
/xtream-favorites.*unreadable/
);
});
it('refuses to export when the storage itself cannot be read', async () => {
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState({
getItem: () => {
throw new DOMException('denied', 'SecurityError');
},
setItem: jest.fn(),
removeItem: jest.fn(),
}),
});
await expect(service.exportBackup()).rejects.toThrow('denied');
});
});
describe('import', () => {
it('restores every list under the id the playlist is imported as', async () => {
const manifest = await exportSeeded();
localStorage.clear();
// The same portal, already present under another id: a merge.
const local = { ...existingXtreamPlaylist, _id: 'local-xtream' };
localStorage.setItem(
KEYS.favorites,
JSON.stringify({
'local-xtream': ['live:1'],
unrelated: ['movie:5'],
})
);
localStorage.setItem(
KEYS.collectionItems,
JSON.stringify({
'local-xtream': {
'live:1': { xtream_id: 1, type: 'live' },
},
})
);
const service = createPlaylistBackupService({
playlistsService: playlistsService([local]),
});
const summary = await service.importBackup(xtreamOnly(manifest));
expect(summary).toMatchObject({ merged: 1, failed: 0 });
// The archive's lists replace the playlist's; others stay put.
expect(read(KEYS.favorites)).toEqual({
'local-xtream': ['movie:501', 'series:77'],
unrelated: ['movie:5'],
});
expect(read(KEYS.recentItems)).toEqual({
'local-xtream': [
{ id: 'live:601', viewedAt: '2026-07-04T18:30:00.000Z' },
{
id: 'movie:501',
viewedAt: '2026-07-03T10:00:00.000Z',
backdropUrl: 'https://img.example.com/heat-still.jpg',
},
],
});
expect(read(KEYS.playbackPositions)).toEqual({
'local-xtream': [{ ...position, playlistId: 'local-xtream' }],
});
// Snapshots are a cache: merged, keyed by typed identity.
expect(read(KEYS.collectionItems)).toEqual({
'local-xtream': {
'live:1': { xtream_id: 1, type: 'live' },
'movie:501': heat,
'series:77': legacyShow,
},
});
// Applied at once, so nothing is left for the store to replay.
expect(
pendingRestore(service).getOrThrow('local-xtream')
).toBeNull();
});
it('round-trips the stored lists through export and import', async () => {
const manifest = await exportSeeded();
localStorage.clear();
const service = createPlaylistBackupService({
playlistsService: playlistsService([]),
});
const summary = await service.importBackup(
JSON.stringify(manifest)
);
expect(summary).toMatchObject({ imported: 2, failed: 0 });
const reexport = createPlaylistBackupService({
playlistsService: playlistsService([
existingXtreamPlaylist,
otherPlaylist,
]),
});
const { manifest: again } = await reexport.exportBackup();
expect(xtreamEntry(again).userState).toEqual(
xtreamEntry(manifest).userState
);
expect(xtreamEntry(again, 'xtream-2').userState).toEqual(
xtreamEntry(manifest, 'xtream-2').userState
);
});
it('leaves snapshots alone for an archive without any, and skips damaged ones', async () => {
const manifest = await exportSeeded();
const entry = xtreamEntry(manifest);
const before = read(KEYS.collectionItems);
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
const withoutSnapshots = {
...manifest,
playlists: [
{
...entry,
userState: {
...entry.userState,
collectionItems: undefined,
},
},
],
};
await service.importBackup(JSON.stringify(withoutSnapshots));
expect(read(KEYS.collectionItems)).toEqual(before);
const damaged = {
...manifest,
playlists: [
{
...entry,
userState: {
...entry.userState,
collectionItems: [
null,
{ xtream_id: 5, type: 'radio' },
{ xtream_id: '6', type: 'movie' },
],
},
},
],
};
const summary = await service.importBackup(JSON.stringify(damaged));
expect(summary.failed).toBe(0);
expect(read(KEYS.collectionItems)).toEqual(before);
});
it('drops entries no runtime could address', async () => {
const manifest = await exportSeeded();
localStorage.clear();
const entry = xtreamEntry(manifest);
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
const hostile = {
...manifest,
playlists: [
{
...entry,
userState: {
...entry.userState,
favorites: [
{ contentType: 'radio', xtreamId: 1 },
{ contentType: 'movie', xtreamId: -4 },
{ contentType: 'movie', xtreamId: 501 },
{ contentType: 'movie', xtreamId: '501' },
],
recentlyViewed: [
{ contentType: 'live', xtreamId: 601 },
],
playbackPositions: [
{ contentXtreamId: 501, contentType: 'vod' },
{ ...position, updatedAt: undefined },
],
},
},
],
};
await service.importBackup(JSON.stringify(hostile));
expect(read(KEYS.favorites)).toEqual({
'xtream-1': ['movie:501'],
});
expect(read(KEYS.recentItems)).toEqual({ 'xtream-1': [] });
const [restored] = read(KEYS.playbackPositions)['xtream-1'];
expect(read(KEYS.playbackPositions)['xtream-1']).toHaveLength(1);
expect(typeof restored.updatedAt).toBe('string');
});
it('fails the entry and keeps the restore parked when a write fails', async () => {
const manifest = await exportSeeded();
localStorage.clear();
const storage = createBrowserBackedStorage();
storage.setItem.mockImplementation((key: string, value: string) => {
if (key === KEYS.recentItems) throw new Error('disk full');
localStorage.setItem(key, value);
});
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState(storage),
});
const summary = await service.importBackup(xtreamOnly(manifest));
expect(summary.failed).toBe(1);
expect(summary.errors[0]).toContain('disk full');
// The Xtream store replays the whole state when the playlist opens.
expect(
pendingRestore(service).getOrThrow('xtream-1')
).not.toBeNull();
// That replay carries no titles, so they are merged regardless.
expect(read(KEYS.collectionItems)).toEqual({
'xtream-1': { 'movie:501': heat, 'series:77': legacyShow },
});
});
it('replaces a damaged title cache but refuses an unreadable storage', async () => {
const manifest = await exportSeeded();
localStorage.setItem(KEYS.collectionItems, '{"xtream-1": {');
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
expect(
(await service.importBackup(xtreamOnly(manifest))).failed
).toBe(0);
expect(read(KEYS.collectionItems)).toEqual({
'xtream-1': { 'movie:501': heat, 'series:77': legacyShow },
});
const denied = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState({
getItem: () => {
throw new DOMException('denied', 'SecurityError');
},
setItem: jest.fn(),
removeItem: jest.fn(),
}),
});
const summary = await denied.importBackup(xtreamOnly(manifest));
expect(summary.failed).toBe(1);
expect(summary.errors[0]).toContain('denied');
});
it('still restores the lists when the snapshots exceed the quota', async () => {
const manifest = await exportSeeded();
localStorage.clear();
const storage = createBrowserBackedStorage();
storage.setItem.mockImplementation((key: string, value: string) => {
if (key === KEYS.collectionItems) {
throw new DOMException('full', 'QuotaExceededError');
}
localStorage.setItem(key, value);
});
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState(storage),
});
const summary = await service.importBackup(
JSON.stringify(manifest)
);
expect(summary.failed).toBe(0);
expect(read(KEYS.favorites)['xtream-1']).toEqual([
'movie:501',
'series:77',
]);
expect(read(KEYS.collectionItems)).toBeNull();
});
});
describe('near the storage quota', () => {
const listKeys = [
KEYS.favorites,
KEYS.recentItems,
KEYS.playbackPositions,
];
/** Storage that refuses a write once all values exceed `budget`. */
function quotaStorage(budget: number) {
const data = new Map<string, string>();
const used = () =>
[...data].reduce((sum, [k, v]) => sum + k.length + v.length, 0);
const storage: UserDataStorage = {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => {
const previous = data.get(key);
data.set(key, value);
if (used() <= budget) return;
if (previous === undefined) data.delete(key);
else data.set(key, previous);
throw new DOMException('full', 'QuotaExceededError');
},
removeItem: (key) => void data.delete(key),
};
return { data, storage };
}
async function importInto(budget: number) {
const manifest = await exportSeeded();
const quota = quotaStorage(budget);
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState(quota.storage),
});
const summary = await service.importBackup(xtreamOnly(manifest));
return { summary, data: quota.data };
}
async function listsSize(): Promise<number> {
const { data } = await importInto(Infinity);
return listKeys.reduce(
(sum, key) => sum + key.length + (data.get(key) ?? '').length,
0
);
}
it('restores the lists that fit without the optional snapshots', async () => {
const { summary, data } = await importInto(await listsSize());
expect(summary.failed).toBe(0);
expect(JSON.parse(data.get(KEYS.favorites) ?? '{}')).toEqual({
'xtream-1': ['movie:501', 'series:77'],
});
expect(data.has(KEYS.collectionItems)).toBe(false);
});
it('leaves the space to the replay when a list does not fit', async () => {
const { summary, data } = await importInto((await listsSize()) - 1);
expect(summary.failed).toBe(1);
// The snapshots would only take room the store's replay needs.
expect(data.has(KEYS.collectionItems)).toBe(false);
});
});
it('follows the Xtream data source on a desktop bridge without its SQLite API', async () => {
// A partial or stale preload: XTREAM_DATA_SOURCE falls back to the
// browser storage, so the backup must read and restore that too.
(window as unknown as { electron?: unknown }).electron = {};
const runtime = { supportsXtreamSqliteDataSource: false };
const manifest = await (async () => {
seed();
const exporter = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
runtime,
});
return (await exporter.exportBackup()).manifest;
})();
expect(xtreamEntry(manifest).userState.favorites).toHaveLength(2);
localStorage.clear();
const importer = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
runtime,
});
const summary = await importer.importBackup(xtreamOnly(manifest));
expect(summary.failed).toBe(0);
expect(read(KEYS.favorites)).toEqual({
'xtream-1': ['movie:501', 'series:77'],
});
expect(pendingRestore(importer).getOrThrow('xtream-1')).toBeNull();
});
it('reads and writes through USER_DATA_STORAGE, not localStorage', async () => {
// The self-hosted server mode provides a server-synced storage here.
const synced = new Map<string, string>();
const serverStorage: UserDataStorage = {
getItem: (key) => synced.get(key) ?? null,
setItem: (key, value) => void synced.set(key, value),
removeItem: (key) => void synced.delete(key),
};
TestBed.configureTestingModule({
providers: [
{ provide: USER_DATA_STORAGE, useValue: serverStorage },
],
});
const pwaXtream = TestBed.inject(PwaXtreamBackupStateService);
seed(serverStorage);
const exporter = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream,
});
const { json, manifest } = await exporter.exportBackup();
expect(xtreamEntry(manifest).userState.favorites).toHaveLength(2);
synced.clear();
const importer = createPlaylistBackupService({
playlistsService: playlistsService([]),
pwaXtream,
});
await importer.importBackup(json);
expect(read(KEYS.favorites, serverStorage)).toEqual({
'xtream-1': ['movie:501', 'series:77'],
});
expect(localStorage.getItem(KEYS.favorites)).toBeNull();
});
function createBrowserBackedStorage() {
return {
getItem: (key: string) => localStorage.getItem(key),
setItem: jest.fn(),
removeItem: (key: string) => localStorage.removeItem(key),
};
}
});
@@ -182,57 +182,49 @@ describe('PlaylistBackupService export → import round-trip', () => {
return entry;
}
it.each([true, false])(
'preserves a fresh pending import on re-export (Electron: %s)',
async (electron) => {
const state = seedState();
state.xtreamFavorites.push({
xtream_id: 501,
type: 'series',
position: 1,
});
const collaborators = createStatefulBackupCollaborators(state);
const service = createPlaylistBackupService(collaborators);
const original = await service.exportBackup();
const originalState = xtreamEntry(original.manifest).userState;
expect(originalState.sourcePins).toHaveLength(1);
state.xtreamCategories = [];
state.xtreamFavorites = [];
state.xtreamRecent = [];
state.playbackPositions = [];
state.sourcePins = [];
collaborators.databaseService.getXtreamImportStatus = async () =>
'idle';
if (!electron) delete electronWindow.electron;
// The browser build applies an import at once (see the PWA Xtream spec),
// so only the desktop leaves one pending while its catalog downloads.
it('preserves a fresh pending import on re-export', async () => {
const state = seedState();
state.xtreamFavorites.push({
xtream_id: 501,
type: 'series',
position: 1,
});
const collaborators = createStatefulBackupCollaborators(state);
const service = createPlaylistBackupService(collaborators);
const original = await service.exportBackup();
const originalState = xtreamEntry(original.manifest).userState;
expect(originalState.sourcePins).toHaveLength(1);
state.xtreamCategories = [];
state.xtreamFavorites = [];
state.xtreamRecent = [];
state.playbackPositions = [];
state.sourcePins = [];
collaborators.databaseService.getXtreamImportStatus = async () =>
'idle';
const summary = await service.importBackup(original.json);
expect(summary.failed).toBe(0);
const snapshot =
collaborators.pendingRestoreService.getSnapshotOrThrow(
'xtream-1'
);
expect(snapshot).not.toBeNull();
// Pending state must remain exportable even while catalog/storage
// reads are unavailable; it is already the complete restore input.
jest.spyOn(
collaborators.databaseService,
'getFavorites'
).mockRejectedValue(new Error('Catalog unavailable'));
jest.spyOn(
collaborators.playbackPositionService,
'getAllPlaybackPositionsOrThrow'
).mockRejectedValue(new Error('Position storage unavailable'));
const exported = await service.exportBackup();
expect(xtreamEntry(exported.manifest).userState).toEqual(
originalState
);
expect(
collaborators.pendingRestoreService.getSnapshotOrThrow(
'xtream-1'
)
).toEqual(snapshot);
}
);
const summary = await service.importBackup(original.json);
expect(summary.failed).toBe(0);
const snapshot =
collaborators.pendingRestoreService.getSnapshotOrThrow('xtream-1');
expect(snapshot).not.toBeNull();
// Pending state must remain exportable even while catalog/storage
// reads are unavailable; it is already the complete restore input.
jest.spyOn(
collaborators.databaseService,
'getFavorites'
).mockRejectedValue(new Error('Catalog unavailable'));
jest.spyOn(
collaborators.playbackPositionService,
'getAllPlaybackPositionsOrThrow'
).mockRejectedValue(new Error('Position storage unavailable'));
const exported = await service.exportBackup();
expect(xtreamEntry(exported.manifest).userState).toEqual(originalState);
expect(
collaborators.pendingRestoreService.getSnapshotOrThrow('xtream-1')
).toEqual(snapshot);
});
it.each([true, false])(
'honors empty pending collections and optional source pins (present: %s)',
@@ -8,11 +8,29 @@ import {
XtreamPendingRestoreState,
} from '@iptvnator/shared/interfaces';
import { PlaylistBackupService } from './playlist-backup.service';
import { PwaXtreamBackupStateService } from './pwa-xtream-backup-state.service';
import {
createBrowserUserDataStorage,
UserDataStorage,
} from './user-data-storage';
import {
XtreamPendingRestoreService,
XtreamPendingRestoreSnapshot,
} from './xtream-pending-restore.service';
/**
* The browser build's Xtream backup state over `storage` — jsdom's
* localStorage unless a spec passes its own — without Angular DI.
*/
export function createPwaXtreamBackupState(
storage: UserDataStorage = createBrowserUserDataStorage()
): PwaXtreamBackupStateService {
return Object.assign(
Object.create(PwaXtreamBackupStateService.prototype) as object,
{ storage }
) as unknown as PwaXtreamBackupStateService;
}
/**
* Shared factory for PlaylistBackupService specs. Instantiates the service
* without Angular DI and replaces every collaborator with jest mocks;
@@ -132,6 +150,13 @@ export function createPlaylistBackupService(
locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })),
replacePlaylistLocks: jest.fn().mockResolvedValue(true),
},
pwaXtream: createPwaXtreamBackupState(),
// Specs pick the runtime by setting or deleting `window.electron`.
runtime: {
get supportsXtreamSqliteDataSource() {
return !!(window as unknown as { electron?: unknown }).electron;
},
},
...overrides,
});
@@ -4,7 +4,9 @@ import { PlaylistsService } from './playlists.service';
import { SettingsStore } from './settings-store.service';
import { DatabaseService } from './database-electron.service';
import { VodSourcePinService } from './vod-source-pin.service';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
import { PlaybackPositionService } from './playback-position.service';
import { PwaXtreamBackupStateService } from './pwa-xtream-backup-state.service';
import { XtreamPendingRestoreService } from './xtream-pending-restore.service';
import { ParentalLockService } from './parental-lock/parental-lock.service';
import { removesParentalLocks } from './parental-lock/parental-lock-store.util';
@@ -62,9 +64,6 @@ export class PlaylistBackupError extends Error {
type PlaylistPortalType = 'm3u' | 'xtream' | 'stalker';
type XtreamContentType = XtreamBackupContentType;
type XtreamCategoryType = XtreamBackupCategoryType;
@Injectable({
providedIn: 'root',
})
@@ -78,6 +77,8 @@ export class PlaylistBackupService {
XtreamPendingRestoreService
);
private readonly parentalLock = inject(ParentalLockService);
private readonly pwaXtream = inject(PwaXtreamBackupStateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private backupImportTail?: Promise<void>;
async exportBackup(): Promise<PlaylistBackupExportPayload> {
@@ -332,7 +333,9 @@ export class PlaylistBackupService {
};
}
// An unavailable pin store is no opinion, never an authoritative [].
if (!this.hasElectronApi()) return entry;
// Same choice as XTREAM_DATA_SOURCE: user state lives where it reads.
if (!this.runtime.supportsXtreamSqliteDataSource)
return this.pwaXtream.exportInto(entry);
const [
liveCategories,
@@ -370,7 +373,7 @@ export class PlaylistBackupService {
...this.optionalLockedXtreamCategories(playlist._id),
favorites: favorites.map((item) => ({
xtreamId: item.xtream_id,
contentType: item.type as XtreamContentType,
contentType: item.type as XtreamBackupContentType,
...(item.added_at ? { addedAt: item.added_at } : {}),
...(item.position !== undefined
? { position: item.position }
@@ -378,7 +381,7 @@ export class PlaylistBackupService {
})),
recentlyViewed: recent.map((item) => ({
xtreamId: item.xtream_id,
contentType: item.type as XtreamContentType,
contentType: item.type as XtreamBackupContentType,
viewedAt: item.viewed_at ?? new Date().toISOString(),
})),
playbackPositions: playbackPositions.map((item) => ({
@@ -933,21 +936,20 @@ export class PlaylistBackupService {
);
}
if (!this.hasElectronApi()) {
return;
}
this.playbackPositionService.assertSupportsAtomicReplacement();
if (!(await this.hasCompletedOfflineCache(playlistId))) {
return;
// Browser storage has no catalog cache to wait for: apply at once.
const sqlite = this.runtime.supportsXtreamSqliteDataSource;
if (sqlite) {
this.playbackPositionService.assertSupportsAtomicReplacement();
if (!(await this.hasCompletedOfflineCache(playlistId))) return;
}
const restoreResult = await this.pendingRestoreService.applyAndConsume(
playlistId,
restoreSnapshot,
(pendingState) =>
this.applyXtreamRestoreState(playlistId, pendingState)
async (state) =>
sqlite
? this.applyXtreamRestoreState(playlistId, state)
: this.pwaXtream.restore(playlistId, state, entry)
);
if (restoreResult !== 'consumed') {
throw new PlaylistBackupError(
@@ -1213,9 +1215,9 @@ export class PlaylistBackupService {
categories: Array<{
hidden: boolean;
xtream_id: number;
type: XtreamCategoryType;
type: XtreamBackupCategoryType;
}>,
categoryType: XtreamCategoryType
categoryType: XtreamBackupCategoryType
): XtreamPendingRestoreState['hiddenCategories'] {
return categories
.filter((category) => category.hidden)
@@ -1359,8 +1361,4 @@ export class PlaylistBackupService {
private getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
private hasElectronApi(): boolean {
return !!(window as Window & { electron?: unknown }).electron;
}
}
@@ -0,0 +1,297 @@
import { inject, Injectable } from '@angular/core';
import {
PlaybackPositionData,
XtreamBackupCollectionItem,
XtreamBackupContentType,
XtreamBackupFavoriteItem,
XtreamBackupRecentlyViewedItem,
XtreamPendingRestoreState,
XtreamPlaylistBackupEntry,
} from '@iptvnator/shared/interfaces';
import { USER_DATA_STORAGE, XTREAM_USER_DATA_KEYS } from './user-data-storage';
type StoredMap = Record<string, unknown>;
type UserDataMap = keyof typeof XTREAM_USER_DATA_KEYS;
interface ResolvedKey {
key: string;
item: { contentType: XtreamBackupContentType; xtreamId: number };
}
const CONTENT_TYPES: readonly unknown[] = ['live', 'movie', 'series'];
/**
* Backup export and restore of the Xtream favorites, recently viewed items,
* playback positions and title snapshots the browser build keeps in
* USER_DATA_STORAGE. `PwaXtreamDataSource` owns these maps in a library this
* one cannot import, so their stored shapes are read here: keys are
* `"<type>:<xtreamId>"`, or a bare provider id written by older builds.
*/
@Injectable({ providedIn: 'root' })
export class PwaXtreamBackupStateService {
private readonly storage = inject(USER_DATA_STORAGE);
/**
* The entry with the playlist's stored lists and snapshots filled in;
* hidden categories and pins, which the browser build does not track,
* stay as `entry` has them. An unreadable store fails the export: restore
* treats these lists as authoritative, so "could not read" written as
* `[]` would turn the archive into an instruction to delete.
*/
exportInto(entry: XtreamPlaylistBackupEntry): XtreamPlaylistBackupEntry {
const playlistId = entry.exportedId;
const stored = this.readMap('collectionItems')[playlistId];
const snapshots = isRecord(stored) ? stored : {};
const favorites = new Map<string, XtreamBackupFavoriteItem>();
for (const key of this.listFor('favorites', playlistId)) {
const ref = resolveKey(key, snapshots);
if (ref) favorites.set(ref.key, ref.item);
}
// Newest view wins, artwork included, as in the data source.
const recent = new Map<string, XtreamBackupRecentlyViewedItem>();
for (const item of this.listFor('recentItems', playlistId)) {
if (!isRecord(item)) continue;
const ref = resolveKey(item['id'], snapshots);
const viewedAt = item['viewedAt'];
if (!ref || typeof viewedAt !== 'string') continue;
const previous = recent.get(ref.key)?.viewedAt;
if (!previous || Date.parse(viewedAt) > Date.parse(previous)) {
recent.set(ref.key, {
...ref.item,
viewedAt,
...backdropOf(item),
});
}
}
const byKey = snapshotsByKey(Object.values(snapshots));
const collectionItems = [
...new Set([...favorites.keys(), ...recent.keys()]),
]
.filter((key) => key in byKey)
.map((key) => byKey[key]);
return {
...entry,
userState: {
...entry.userState,
favorites: [...favorites.values()],
recentlyViewed: [...recent.values()],
playbackPositions: this.listFor('playbackPositions', playlistId)
.filter(isPlaybackPosition)
.map((position) => ({ ...position })),
...(collectionItems.length > 0 ? { collectionItems } : {}),
},
};
}
/**
* Replaces the playlist's favorites, history and positions with the
* restore state, keyed and remapped to `playlistId`, then merges the
* archived title snapshots into the playlist's own.
*
* Lists first, so the optional cache never takes space they need. A
* failed write throws with earlier ones applied, and the caller keeps the
* pending snapshot for the Xtream store to replay on the next open. The
* replay carries no titles (they come from `entry`, not `state`), so they
* are still merged then, unless storage is full and they would crowd it.
*/
async restore(
playlistId: string,
state: XtreamPendingRestoreState,
entry: XtreamPlaylistBackupEntry
): Promise<void> {
// A user-supplied file: the field may be anything.
const archived: unknown = entry.userState.collectionItems;
const snapshots = snapshotsByKey(
Array.isArray(archived) ? archived : []
);
try {
this.replaceLists(playlistId, state);
} catch (error) {
if (!isQuotaExceeded(error)) {
try {
this.mergeSnapshots(playlistId, snapshots);
} catch {
// The list failure is the one to report.
}
}
throw error;
}
this.mergeSnapshots(playlistId, snapshots);
}
private replaceLists(
playlistId: string,
state: XtreamPendingRestoreState
): void {
const favorites = state.favorites
.map((item) => typedKey(item.contentType, item.xtreamId))
.filter((key): key is string => key !== null);
this.writeFor('favorites', playlistId, [...new Set(favorites)]);
const recent: StoredMap[] = [];
for (const item of state.recentlyViewed) {
const id = typedKey(item.contentType, item.xtreamId);
if (id !== null && typeof item.viewedAt === 'string') {
recent.push({
id,
viewedAt: item.viewedAt,
...backdropOf(item),
});
}
}
this.writeFor('recentItems', playlistId, recent);
const now = new Date().toISOString();
this.writeFor(
'playbackPositions',
playlistId,
state.playbackPositions.filter(isPlaybackPosition).map((item) => ({
...item,
playlistId,
updatedAt: item.updatedAt ?? now,
}))
);
}
/**
* A display cache the data source itself writes on a best-effort basis:
* a damaged one is replaced, as the data source would, and a full
* storage skips it. Its titles return once the catalog loads.
*/
private mergeSnapshots(
playlistId: string,
incoming: Record<string, XtreamBackupCollectionItem>
): void {
if (Object.keys(incoming).length === 0) return;
const all = this.readMap('collectionItems', { replaceDamaged: true });
const current = isRecord(all[playlistId]) ? all[playlistId] : {};
try {
this.writeMap('collectionItems', {
...all,
[playlistId]: { ...current, ...incoming },
});
} catch (error) {
if (!isQuotaExceeded(error)) throw error;
}
}
private listFor(name: UserDataMap, playlistId: string): unknown[] {
const list = this.readMap(name)[playlistId];
if (list === undefined) return [];
if (!Array.isArray(list)) {
throw new Error(
`Stored ${XTREAM_USER_DATA_KEYS[name]} of playlist "${playlistId}" are unreadable.`
);
}
return list;
}
private writeFor(name: UserDataMap, playlistId: string, list: unknown[]) {
this.writeMap(name, { ...this.readMap(name), [playlistId]: list });
}
/**
* Strict, unlike the data source: a storage error or a damaged value
* throws rather than read as "nothing stored", so a write never replaces
* entries it could not read. A cache may `replaceDamaged` values.
*/
private readMap(
name: UserDataMap,
{ replaceDamaged = false } = {}
): StoredMap {
const key = XTREAM_USER_DATA_KEYS[name];
const raw = this.storage.getItem(key);
if (raw === null) return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
parsed = null;
}
if (isRecord(parsed)) return parsed;
if (replaceDamaged) return {};
throw new Error(`Stored ${key} are unreadable.`);
}
private writeMap(name: UserDataMap, map: StoredMap): void {
this.storage.setItem(XTREAM_USER_DATA_KEYS[name], JSON.stringify(map));
}
}
/**
* A typed key carries its own identity. A bare provider id resolves only
* through the snapshot stored under it, as the data source does without a
* complete catalog: ids repeat across live, movie and series, so a guess
* could restore a different title. An unresolvable id is left out.
*/
function resolveKey(key: unknown, snapshots: StoredMap): ResolvedKey | null {
if (typeof key === 'string' && key.includes(':')) {
const [type, id, extra] = key.split(':');
return extra === undefined ? resolved(type, Number(id)) : null;
}
if (typeof key !== 'number' && typeof key !== 'string') return null;
const snapshot = snapshots[String(key)];
return isRecord(snapshot) && snapshot['xtream_id'] === Number(key)
? resolved(snapshot['type'], Number(key))
: null;
}
function resolved(type: unknown, xtreamId: number): ResolvedKey | null {
const key = typedKey(type, xtreamId);
if (key === null) return null;
const contentType = type as XtreamBackupContentType;
return { key, item: { contentType, xtreamId } };
}
function typedKey(type: unknown, xtreamId: unknown): string | null {
return CONTENT_TYPES.includes(type) &&
typeof xtreamId === 'number' &&
Number.isSafeInteger(xtreamId) &&
xtreamId > 0
? `${type}:${xtreamId}`
: null;
}
/** Snapshots by typed key, whatever key they were stored under. */
function snapshotsByKey(
items: unknown[]
): Record<string, XtreamBackupCollectionItem> {
const byKey: Record<string, XtreamBackupCollectionItem> = {};
for (const item of items) {
if (!isRecord(item)) continue;
const key = typedKey(item['type'], item['xtream_id']);
if (key !== null) byKey[key] = item as XtreamBackupCollectionItem;
}
return byKey;
}
/** Artwork saved with a view, in either spelling the data source reads. */
function backdropOf(item: object): { backdropUrl?: string } {
const record = item as StoredMap;
const value = record['backdropUrl'] ?? record['backdrop_url'];
return typeof value === 'string' && value.trim()
? { backdropUrl: value.trim() }
: {};
}
function isPlaybackPosition(value: unknown): value is PlaybackPositionData {
return (
isRecord(value) &&
Number.isFinite(value['contentXtreamId']) &&
(value['contentType'] === 'vod' ||
value['contentType'] === 'episode') &&
Number.isFinite(value['positionSeconds'])
);
}
function isQuotaExceeded(error: unknown): boolean {
return error instanceof DOMException && error.name === 'QuotaExceededError';
}
function isRecord(value: unknown): value is StoredMap {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
@@ -20,6 +20,18 @@ export function createBrowserUserDataStorage(): UserDataStorage {
};
}
/**
* The PWA Xtream user-data maps in USER_DATA_STORAGE, each a JSON object
* keyed by playlist id. Shared with the playlist backup, which reads and
* writes them directly.
*/
export const XTREAM_USER_DATA_KEYS = {
favorites: 'xtream-favorites',
recentItems: 'xtream-recent-items',
collectionItems: 'xtream-collection-items',
playbackPositions: 'xtream-playback-positions',
} as const;
/**
* Browser persistence for PWA user data such as Xtream favorites, recently
* viewed items and playback positions. Defaults to `localStorage`; the
+1
View File
@@ -103,6 +103,7 @@ export * from './lib/stalker-serial-details.interface';
export * from './lib/stalker-vod-details.interface';
// Unified VOD details (discriminated union + adapters)
export * from './lib/provider-metadata.util';
export * from './lib/vod-details-adapters';
export * from './lib/vod-details-item.interface';
@@ -62,6 +62,12 @@ export interface XtreamBackupRecentlyViewedItem {
contentType: XtreamBackupContentType;
xtreamId: number;
viewedAt: string;
/**
* Artwork the browser build saved with this view, shown ahead of the
* title snapshot's. Optional; the desktop build neither writes nor
* reads it.
*/
backdropUrl?: string;
}
/**
@@ -76,6 +82,17 @@ export interface XtreamBackupSourcePin {
updatedAt?: string;
}
/**
* A favorite or recently viewed title as the browser build last displayed it,
* so the restored lists show titles and artwork before the provider catalog
* is fetched again. Every other field is the item exactly as it was stored.
*/
export interface XtreamBackupCollectionItem {
type: XtreamBackupContentType;
xtream_id: number;
[field: string]: unknown;
}
export interface XtreamBackupUserState {
hiddenCategories: XtreamBackupHiddenCategory[];
favorites: XtreamBackupFavoriteItem[];
@@ -83,6 +100,12 @@ export interface XtreamBackupUserState {
playbackPositions: PlaybackPositionData[];
/** Optional: absent in archives written before multi-source existed. */
sourcePins?: XtreamBackupSourcePin[];
/**
* Optional and written only by the browser build, which keeps no catalog
* between sessions. A cache, not user state: restore merges it into the
* playlist's snapshots, and the desktop build ignores it.
*/
collectionItems?: XtreamBackupCollectionItem[];
/**
* Parental-lock categories, same shape as `hiddenCategories`. Optional
* like `sourcePins`: absence means the archive predates the lock and
@@ -0,0 +1,49 @@
import {
firstProviderText,
readProviderRating,
readProviderText,
} from './provider-metadata.util';
describe('readProviderText', () => {
it.each([undefined, null, '', ' ', 'N/A', 'n/a', ' N/a ', '-', ' - '])(
'reads %p as absent',
(value) => {
expect(readProviderText(value)).toBeUndefined();
}
);
it('trims real text and keeps a value that only contains a stand-in', () => {
expect(readProviderText(' Drama ')).toBe('Drama');
expect(readProviderText('Sci-Fi')).toBe('Sci-Fi');
expect(readProviderText('N/A Studios')).toBe('N/A Studios');
});
it('reads finite numbers as text and nothing else', () => {
expect(readProviderText(2011)).toBe('2011');
expect(readProviderText(0)).toBe('0');
expect(readProviderText(Number.NaN)).toBeUndefined();
expect(readProviderText({})).toBeUndefined();
});
});
describe('firstProviderText', () => {
it('skips placeholders and blanks to reach the usable fallback', () => {
expect(firstProviderText('N/A', '', ' ', '2011')).toBe('2011');
expect(firstProviderText('-', undefined)).toBeUndefined();
});
});
describe('readProviderRating', () => {
it.each(['0', '0.0', '0,0', 0, 'N/A', '-', ''])(
'reads %p as unrated',
(value) => {
expect(readProviderRating(value)).toBeUndefined();
}
);
it('keeps real scores as the provider wrote them', () => {
expect(readProviderRating('7.4')).toBe('7.4');
expect(readProviderRating('6,8')).toBe('6,8');
expect(readProviderRating(8)).toBe('8');
});
});
@@ -0,0 +1,59 @@
/**
* Readers for provider detail metadata (`get_vod_info`, Stalker VOD rows).
*
* Portals fill a field they have nothing for with a stand-in instead of
* leaving it empty — `"N/A"` (in any case) or a lone `"-"` — and a detail
* template that renders any truthy value then shows the stand-in as if it
* were data: a `⭐ N/A` chip, an "N/A" director, an "N/A" plot. The detail
* shell's contract is that missing data simply is not rendered, so these
* readers turn every stand-in into "absent" where provider rows are MAPPED,
* and templates keep plain truthiness guards instead of each learning the
* list.
*
* Keep the list to stand-ins providers were actually seen sending: every
* entry also erases a genuine value that happens to read the same.
*/
const PROVIDER_PLACEHOLDERS: ReadonlySet<string> = new Set(['n/a', '-']);
/**
* Trimmed provider text, or `undefined` when it is blank or a placeholder.
* Finite numbers are read as their text form; anything else is absent.
*/
export function readProviderText(value: unknown): string | undefined {
const text =
typeof value === 'string'
? value.trim()
: typeof value === 'number' && Number.isFinite(value)
? String(value)
: '';
return text && !PROVIDER_PLACEHOLDERS.has(text.toLowerCase())
? text
: undefined;
}
/**
* The first candidate that carries real text. `a ?? b` would stop at a
* placeholder (or a blank) and never reach the usable fallback behind it.
*/
export function firstProviderText(...values: unknown[]): string | undefined {
for (const value of values) {
const text = readProviderText(value);
if (text !== undefined) {
return text;
}
}
return undefined;
}
/**
* {@link readProviderText} for an IMDb / Kinopoisk score, where zero is a
* placeholder too: both scales start at 1, so a `"0"` / `"0.0"` is the
* panel's way of saying "unrated" (TMDB's `vote_average` of 0 is read the
* same way by the enrichment merge).
*/
export function readProviderRating(value: unknown): string | undefined {
const text = readProviderText(value);
return text !== undefined && Number(text.replace(',', '.')) === 0
? undefined
: text;
}
@@ -64,4 +64,6 @@ export interface StalkerVodInfo {
* this to send Discover clicks to the right media type.
*/
tmdb_media_type?: TmdbMediaType;
/** See `XtreamVodInfo.tmdb_supplied_release_date` — same contract */
tmdb_supplied_release_date?: boolean;
}
@@ -71,6 +71,116 @@ describe('normalized release year', () => {
});
});
describe('provider placeholders in the details view model', () => {
const PLACEHOLDER_FREE_FIELDS = [
'description',
'posterUrl',
'backdropUrl',
'year',
'genre',
'duration',
'country',
'director',
'actors',
'ratingImdb',
'ratingKinopoisk',
] as const;
it('renders none of the stand-ins a Stalker portal sends for empty fields', () => {
const meta = normalizeStalkerVod({
id: '1',
cmd: '',
info: {
name: 'Placeholder Film',
o_name: 'N/A',
movie_image: 'N/A',
description: 'N/A',
actors: 'n/a',
director: '-',
releasedate: 'N/A',
genre: ' N/A ',
rating_imdb: 'N/A',
rating_kinopoisk: '0',
},
});
// A placeholder original title must not shadow the real one
expect(meta.title).toBe('Placeholder Film');
for (const field of PLACEHOLDER_FREE_FIELDS) {
expect(meta[field]).toBeUndefined();
}
});
it('renders none of the stand-ins in an Xtream detail response', () => {
const meta = normalizeXtreamVod({
info: {
kinopoisk_url: '',
tmdb_id: 0,
name: 'N/A',
o_name: '',
cover_big: '-',
movie_image: 'N/A',
releasedate: 'N/A',
episode_run_time: 0,
youtube_trailer: '',
director: 'N/A',
actors: 'N/A',
cast: '-',
description: 'N/A',
plot: 'n/a',
age: '',
mpaa_rating: '',
rating_count_kinopoisk: 0,
country: 'N/A',
genre: '-',
backdrop_path: ['N/A'],
duration_secs: 0,
duration: 'N/A',
video: [],
audio: [],
bitrate: 0,
rating: 0,
rating_imdb: '0.0',
rating_kinopoisk: 'N/A',
},
movie_data: {
stream_id: 5,
name: 'Catalog Title',
added: '',
category_id: '1',
container_extension: 'mp4',
custom_sid: null,
direct_source: '',
},
});
expect(meta.title).toBe('Catalog Title');
for (const field of PLACEHOLDER_FREE_FIELDS) {
expect(meta[field]).toBeUndefined();
}
});
it('falls through a placeholder to the next usable Xtream field', () => {
const meta = normalizeXtreamVod({
info: {
name: 'Fallback Film',
description: 'N/A',
plot: 'A plot from the second field',
actors: '-',
cast: 'First Actor, Second Actor',
duration: 'N/A',
duration_secs: 5400,
rating_imdb: '7.2',
} as never,
});
expect(meta.description).toBe('A plot from the second field');
expect(meta.actors).toBe('First Actor, Second Actor');
expect(meta.duration).toBe('1h 30m');
expect(meta.ratingImdb).toBe('7.2');
});
});
describe('youtubeEmbedUrl', () => {
it('builds an embed URL from a plain video id (TMDB format)', () => {
expect(youtubeEmbedUrl('zAGVQLHvwOY')).toBe(
@@ -1,5 +1,10 @@
import { XtreamVodDetails, getXtreamVodInfo } from './xtream-vod-details.interface';
import { StalkerVodDetails } from './stalker-vod-details.interface';
import {
firstProviderText,
readProviderRating,
readProviderText,
} from './provider-metadata.util';
import { parseFacetYear } from './tmdb.interface';
import {
NormalizedVodMeta,
@@ -11,24 +16,28 @@ import {
/**
* Normalizes Xtream VOD details into a display-friendly format.
* Handles the various field name variations in Xtream API responses.
* Provider stand-ins ("N/A", "-", an unrated "0") read as absent — see
* `provider-metadata.util.ts`.
*/
export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
const info = getXtreamVodInfo(item);
const movieData = item?.movie_data;
return {
title: info?.name || movieData?.name || 'Unknown',
description: info?.description || info?.plot,
posterUrl: info?.movie_image || info?.cover_big,
backdropUrl: info?.backdrop_path?.[0],
year: extractYear(info?.releasedate),
genre: info?.genre,
duration: info?.duration || formatDuration(info?.duration_secs),
country: info?.country,
director: info?.director,
actors: info?.actors || info?.cast,
ratingImdb: info?.rating_imdb,
ratingKinopoisk: info?.rating_kinopoisk,
title: firstProviderText(info?.name, movieData?.name) ?? 'Unknown',
description: firstProviderText(info?.description, info?.plot),
posterUrl: firstProviderText(info?.movie_image, info?.cover_big),
backdropUrl: readProviderText(info?.backdrop_path?.[0]),
year: extractYear(readProviderText(info?.releasedate)),
genre: readProviderText(info?.genre),
duration:
readProviderText(info?.duration) ??
formatDuration(info?.duration_secs),
country: readProviderText(info?.country),
director: readProviderText(info?.director),
actors: firstProviderText(info?.actors, info?.cast),
ratingImdb: readProviderRating(info?.rating_imdb),
ratingKinopoisk: readProviderRating(info?.rating_kinopoisk),
youtubeTrailer: info?.youtube_trailer,
tmdbCast: info?.tmdb_cast,
tmdbDirectors: info?.tmdb_directors,
@@ -42,24 +51,26 @@ export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
* Normalizes Stalker VOD details into a display-friendly format.
* Stalker has fewer metadata fields than Xtream.
* Prefers o_name (original title) over name (often formatted/generic).
* Provider stand-ins ("N/A", "-", an unrated "0") read as absent — see
* `provider-metadata.util.ts`.
*/
export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta {
const info = item?.info;
return {
title: info?.o_name || info?.name || 'Unknown',
description: info?.description,
posterUrl: info?.movie_image,
title: firstProviderText(info?.o_name, info?.name) ?? 'Unknown',
description: readProviderText(info?.description),
posterUrl: readProviderText(info?.movie_image),
// Stalker portals never provide a backdrop; TMDB enrichment can
backdropUrl: info?.tmdb_backdrop,
year: extractYear(info?.releasedate),
genre: info?.genre,
year: extractYear(readProviderText(info?.releasedate)),
genre: readProviderText(info?.genre),
duration: undefined, // Stalker doesn't provide duration
country: undefined, // Stalker doesn't provide country
director: info?.director,
actors: info?.actors,
ratingImdb: info?.rating_imdb,
ratingKinopoisk: info?.rating_kinopoisk,
director: readProviderText(info?.director),
actors: readProviderText(info?.actors),
ratingImdb: readProviderRating(info?.rating_imdb),
ratingKinopoisk: readProviderRating(info?.rating_kinopoisk),
// Stalker portals provide no trailers; TMDB enrichment can
youtubeTrailer: info?.tmdb_trailer,
tmdbCast: info?.tmdb_cast,
+1
View File
@@ -9,6 +9,7 @@ export * from './lib/confirm-dialog/confirm-dialog.component';
export * from './lib/confirm-dialog/dialog.service';
export * from './lib/content-hero/content-hero.component';
export * from './lib/expandable-text/expandable-text.component';
export * from './lib/form-field-defaults/form-field-defaults';
export * from './lib/external-playback-dock/external-playback-dock.component';
export * from './lib/portal-detail-shell/content-about.component';
export * from './lib/portal-detail-shell/detail-template.directives';
@@ -22,6 +22,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { debounceTime, from, Subject, switchMap } from 'rxjs';
import { provideFormFieldDefaults } from '../../form-field-defaults/form-field-defaults';
export interface EpgMappingDialogData {
channelKey: string;
@@ -39,6 +40,7 @@ const SEARCH_MIN_CHARS = 2;
@Component({
selector: 'app-epg-mapping-dialog',
providers: [provideFormFieldDefaults()],
templateUrl: './epg-mapping-dialog.component.html',
styleUrl: './epg-mapping-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -0,0 +1,25 @@
import { Provider } from '@angular/core';
import {
MAT_FORM_FIELD_DEFAULT_OPTIONS,
MatFormFieldDefaultOptions,
} from '@angular/material/form-field';
/** The app's outlined, dynamically sized form fields (see m3-theme.scss). */
export const APP_FORM_FIELD_DEFAULTS: MatFormFieldDefaultOptions = {
appearance: 'outline',
subscriptSizing: 'dynamic',
};
/**
* Provide the app's form-field defaults on a component that renders
* `<mat-form-field>`. They are not provided in the root injector: importing
* the token there loads `@angular/material/form-field` (and, since Material
* 22.2, `@angular/forms`) on startup, while every form field lives in a lazy
* chunk. `tools/nx/check-form-field-defaults.mjs` enforces this.
*/
export function provideFormFieldDefaults(): Provider {
return {
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
useValue: APP_FORM_FIELD_DEFAULTS,
};
}
@@ -27,6 +27,7 @@ import {
PARENTAL_LOCK_PIN_MAX_LENGTH,
PARENTAL_LOCK_PIN_MIN_LENGTH,
} from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '../form-field-defaults/form-field-defaults';
export type ParentalLockPinDialogMode = 'unlock' | 'set';
@@ -59,6 +60,7 @@ export interface ParentalLockPinDialogData {
*/
@Component({
selector: 'app-parental-lock-pin-dialog',
providers: [provideFormFieldDefaults()],
templateUrl: './parental-lock-pin-dialog.component.html',
styleUrl: './parental-lock-pin-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -166,18 +166,20 @@ describe('VodDetailsComponent offline playback', () => {
};
const render = async ({
item = STALKER_VOD,
playbackPosition = null,
externalPlayback = null,
providerOnly = false,
isWatched = false,
}: {
item?: VodDetailsItem;
playbackPosition?: number | null;
externalPlayback?: ExternalPlayerSession | null;
providerOnly?: boolean;
isWatched?: boolean;
} = {}) => {
fixture = TestBed.createComponent(VodDetailsComponent);
fixture.componentRef.setInput('item', STALKER_VOD);
fixture.componentRef.setInput('item', item);
fixture.componentRef.setInput(
'playbackSessionKey',
'stalker-host-owned-key'
@@ -378,6 +380,46 @@ describe('VodDetailsComponent offline playback', () => {
fixture?.destroy();
});
it('renders no provider stand-ins in the hero or the About block', async () => {
await render({
item: createStalkerVodItem(
{
id: '43',
cmd: '/media/file_43.mpg',
info: {
name: 'Placeholder Movie',
o_name: 'N/A',
movie_image: 'N/A',
description: 'N/A',
actors: 'N/A',
director: 'N/A',
releasedate: 'N/A',
genre: '-',
rating_imdb: 'N/A',
rating_kinopoisk: '0',
},
},
'stalker-1'
),
});
// Watch state stamps the same templates into the About block
fixture.componentRef.setInput('inlinePlayback', {
streamUrl: 'https://portal.example/movie.mp4',
title: 'Placeholder Movie',
});
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Placeholder Movie');
expect(text).not.toMatch(/N\/A/i);
expect(text).not.toContain('⭐');
expect(
fixture.nativeElement.querySelectorAll(
'.details__tag:not(.details__tag--clickable), .details__meta-item'
).length
).toBe(0);
});
it('renders Offline and plays a completed download from the primary action', async () => {
completeDownload();
await render();
@@ -885,6 +885,45 @@ describe('DashboardRecommendationsService', () => {
expect(service.items().map((item) => item.title)).toContain('Godzilla');
});
it('ignores a release date the TMDB merge supplied when excluding', async () => {
// The portal sent no date, so the stored one is the year of
// whatever TMDB matched. It must not gate the exact tier, or a
// namesake match would keep the watched title on the rail.
enrichMovie.mockResolvedValue({
recommendations: {
results: [
...recTitles
.slice(0, 5)
.map((title, i) => rec(100 + i, title)),
rec(200, 'Quarry Lights', 2014),
],
},
});
recentAll = [
...recentVod,
{
title: 'Quarry Lights',
type: 'movie',
stalker_item: {
id: '9',
category_id: 'vod',
info: {
name: 'Quarry Lights',
releasedate: '1954-11-03',
tmdb_supplied_release_date: true,
},
},
} as ActivityStub,
];
const service = createService();
await service.load();
expect(service.items().map((item) => item.title)).not.toContain(
'Quarry Lights'
);
});
it('excludes a watched title whose name ends in a year', async () => {
// "Blade Runner 2049" is a 2017 film; the 2049 belongs to the
// NAME, so it must not be read as a release year and used to
@@ -52,7 +52,10 @@ export type RecommendationCandidate = Omit<
* gating that key on a title-derived 2049 would fail to exclude the very
* film the user just watched (TMDB calls it 2017). Only Stalker rows
* carry a real date (`info.releasedate`); everything else yields `null`,
* which keeps the exact tier excluding unconditionally.
* which keeps the exact tier excluding unconditionally. So does a date the
* TMDB merge filled in itself (`tmdb_supplied_release_date`): it is the
* year of whatever TMDB matched, and a namesake match would otherwise stop
* the watched title from excluding itself.
*
* The base tier is different by construction — its key exists only
* because a trailing year was stripped off — so it keeps using that year.
@@ -63,6 +66,9 @@ export function trustedReleaseYear(
const info = (
item.stalker_item as { info?: Record<string, unknown> } | undefined
)?.info;
if (info?.['tmdb_supplied_release_date'] === true) {
return null;
}
const releaseDate = info?.['releasedate'];
return typeof releaseDate === 'string' ? extractYear(releaseDate) : null;
}
@@ -1,6 +1,13 @@
import { PortalRailSection } from '@iptvnator/portal/shared/util';
export const SEARCH_INPUT_DEBOUNCE_MS = 350;
/**
* The one debounce between a keystroke in the header search box and the
* applied term; pages that search on the applied term (global search
* included) run at once. 300 ms sits above the inter-key gap of steady
* typing, so a word typed in one go is applied once.
* See docs/architecture/performance-journeys.md (J4).
*/
export const SEARCH_INPUT_DEBOUNCE_MS = 300;
/**
* How long a purely local Xtream load (reading the saved catalog from SQLite)