Merge remote-tracking branch 'origin/master' into perf/zoneless-flip

# Conflicts:
#	apps/web/src/app/app.config.ts
#	tools/performance/journey-baselines.json
This commit is contained in:
4gray committed 2026-10-13 21:57:56 +02:00
commit 0622794253
52 files changed
+1934 -118

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: backup
---
Backups made in the browser and self-hosted versions now include Xtream favorites, recently viewed items and resume positions. Restoring such a backup brings them back right away, under whichever playlist the portal is imported as.
@@ -0,0 +1,7 @@
---
type: perf
area: performance
---
The app loads less code at startup: the text field component and its form
helpers now load with the screens that show a form instead of with the app.
@@ -0,0 +1,6 @@
---
type: fix
area: stalker
---
Stalker series details now keep their clickable genre and country chips after TMDB fills in the details, so you can jump to Discover from them as you can for movies.
+8 -4
View File
@@ -1,10 +1,11 @@
# Every Dependabot PR triggers the full pipeline (~15 jobs), so version
# updates are batched weekly. Nx minor+patch updates use a dedicated group so
# official packages move together; Nx majors are manual coordinated migrations.
# Native packaging, parser, and version-locked playback dependencies stay in
# standalone PRs; other minor+patch updates are grouped per ecosystem. Security
# updates are separate and are not limited by this schedule; CI enforces complete
# Nx lockstep.
# Electron majors are held while Linux armv7l packages ship: Electron 44
# dropped the linux-armv7l binary. Native packaging, parser, and
# version-locked playback dependencies stay in standalone PRs; other
# minor+patch updates are grouped per ecosystem. Security updates are separate
# and are not limited by this schedule; CI enforces complete Nx lockstep.
version: 2
updates:
- package-ecosystem: npm
@@ -21,6 +22,9 @@ updates:
- dependency-name: '@nx/*'
update-types:
- version-update:semver-major
- dependency-name: electron
update-types:
- version-update:semver-major
groups:
nx-version-updates:
applies-to: version-updates
+6
View File
@@ -433,6 +433,12 @@ jobs:
- name: Validate icon-only button names
run: pnpm run a11y:icon-buttons:validate
# Form-field defaults are provided per lazy component: the root
# token import would load Material form-field and @angular/forms
# on startup (tools/nx/check-form-field-defaults.mjs).
- name: Validate form-field defaults
run: pnpm run ui:form-field-defaults:validate
# Nx rejects a non-parallel task with continuous dependencies, and
# the Playwright plugin infers serve dependencies only when CI is
# unset, so this checks both the local and the CI inference.
-5
View File
@@ -14,7 +14,6 @@ import {
importProvidersFrom,
provideZonelessChangeDetection,
} from '@angular/core';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideServiceWorker } from '@angular/service-worker';
@@ -166,9 +165,5 @@ export const appConfig: ApplicationConfig = {
...provideParentalLockPrompt(),
provideParentalLockPlaylistCleanup(),
...provideXtreamDataSource(),
{
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
useValue: { appearance: 'outline', subscriptSizing: 'dynamic' },
},
],
};
@@ -20,6 +20,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { UpdateChannelOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
interface SettingsUpdateStatusChip {
readonly labelKey: string;
@@ -28,6 +29,7 @@ interface SettingsUpdateStatusChip {
@Component({
selector: 'app-settings-about-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
MatFormFieldModule,
@@ -23,9 +23,11 @@ import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
import { TranslateModule } from '@ngx-translate/core';
import { EpgViewModeOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-epg-section',
providers: [provideFormFieldDefaults()],
imports: [
EpgSourceStatusComponent,
MatButtonModule,
@@ -20,9 +20,11 @@ import {
ThemeOption,
} from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-general-section',
providers: [provideFormFieldDefaults()],
imports: [
CommonModule,
MatFormFieldModule,
@@ -9,9 +9,11 @@ import {
} from '@angular/material/slide-toggle';
import { TranslateModule } from '@ngx-translate/core';
import { ParentalLockRelockMinutes } from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-parental-lock-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
MatFormFieldModule,
@@ -22,9 +22,11 @@ import {
} from '@iptvnator/shared/interfaces';
import { SettingsPlayerOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-playback-section',
providers: [provideFormFieldDefaults()],
imports: [
CommonModule,
MatButtonModule,
@@ -15,9 +15,11 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { QRCodeComponent } from 'angularx-qrcode';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-remote-control-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
MatFormFieldModule,
@@ -17,11 +17,13 @@ import { TranslateModule } from '@ngx-translate/core';
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
import type { TmdbCacheStats } from '@iptvnator/shared/interfaces';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
@Component({
selector: 'app-settings-tmdb-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
MatFormFieldModule,
+3 -2
View File
@@ -279,8 +279,9 @@ html {
// ─── Global refined outlined inputs ──────────────────────────────────────────
// Applied app-wide AFTER mat.all-component-themes so these overrides win the
// cascade. Pairs with MAT_FORM_FIELD_DEFAULT_OPTIONS (outline + dynamic) set
// in app.config.ts.
// cascade. Pairs with the outline + dynamic MAT_FORM_FIELD_DEFAULT_OPTIONS
// that provideFormFieldDefaults() (@iptvnator/ui/components) sets on each
// component rendering a form field.
$app-refined-input-density: mat.define-theme((
density: (scale: -2),
));
@@ -1033,6 +1033,13 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`.
## Forms
Every `<mat-form-field>` is outlined with a dynamic subscript. A component
that renders one adds `providers: [provideFormFieldDefaults()]`
(`@iptvnator/ui/components/form-field-defaults`); the root injector does not
provide the defaults, because the token import there would load Material's
form field and `@angular/forms` on startup. `pnpm run
ui:form-field-defaults:validate` (in CI) reports a missing provider.
The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one
vocabulary, so a field reads the same wherever it appears:
+47 -1
View File
@@ -12,6 +12,8 @@ settings screen.
- Manifest types: `libs/shared/interfaces/src/lib/playlist-backup.interface.ts`
- Xtream pending restore storage:
`libs/services/src/lib/xtream-pending-restore.service.ts`
- Browser (PWA) Xtream user state:
`libs/services/src/lib/pwa-xtream-backup-state.service.ts`
## Manifest Contract
@@ -61,9 +63,12 @@ Xtream backups export only connection metadata plus portable user state.
- User state:
- hidden categories by `{ categoryType, xtreamId }`
- favorites by `{ contentType, xtreamId, addedAt?, position? }`
- recently viewed by `{ contentType, xtreamId, viewedAt }`
- recently viewed by `{ contentType, xtreamId, viewedAt, backdropUrl? }`
(`backdropUrl` is written and read by the browser build only)
- playback positions as `PlaybackPositionData[]`
- optional VOD source pins by `{ matchKey, contentId, updatedAt? }`
- optional title snapshots `collectionItems` (browser build only; see
[Browser runtime](#browser-runtime))
An imported Xtream archive can wait for its catalog to download. Export reads
that pending snapshot before consulting the catalog: its hidden categories,
@@ -225,6 +230,47 @@ categories/favorites/history can
remain after a later failure, but the snapshot remains available to retry the
complete restore. It is consumed only after every restore step succeeds.
### Browser Runtime
Whenever `XTREAM_DATA_SOURCE` resolves to `PwaXtreamDataSource` — the
browser, or a desktop bridge without the Xtream SQLite API
(`RuntimeCapabilitiesService.supportsXtreamSqliteDataSource`) — Xtream
favorites, recently viewed items, playback positions and title snapshots live
in the `USER_DATA_STORAGE` maps owned by `PwaXtreamDataSource`
(`XTREAM_USER_DATA_KEYS`; see
[PWA portal user data](./pwa-self-hosted.md#pwa-portal-user-data)).
`PwaXtreamBackupStateService` reads and writes them through that token, so the
self-hosted server storage is covered too.
- Export converts each playlist's entries to the archive shape. Typed keys
(`movie:42`) carry their identity; a legacy bare id resolves only through
the snapshot stored under it, and is left out when none identifies it.
Recently viewed keeps the newest view per title, with the artwork saved
for that view as `backdropUrl`. `collectionItems` carries
the snapshots of exported favorites and history, and is omitted when there
are none. Hidden categories stay `[]` and `sourcePins` stays absent: the
browser tracks neither.
- An unreadable map (storage error, malformed JSON, a playlist entry that is
not a list) fails the export instead of being written as an empty list.
- A pending restore snapshot still wins over the stored maps, as on desktop.
- Import parks the restore state like the desktop does, then applies it at
once through `XtreamPendingRestoreService.applyAndConsume`: the browser has
no catalog cache to wait for. Favorites, history and positions of the
imported playlist id are replaced (positions get that id); other playlists'
entries are untouched. `collectionItems` merge into the playlist's
snapshots, keyed by typed identity; an archive without them leaves the
snapshots alone, malformed items are skipped, a damaged cache is replaced,
and a quota error on this cache does not fail the restore.
- The lists are written before the optional title snapshots, so the cache
never takes storage space they need (as for additions; see the
[snapshot-storage contract](./xtream-portal-compatibility.md)). A failed
write fails the entry and keeps the snapshot parked, so the Xtream store
replays the complete state when the playlist next opens. That replay
carries no title snapshots, so they are still merged after a failed list
write, unless storage is full and they would take room the replay needs.
Older builds ignore `collectionItems`; the manifest version stays `1`.
## Current UX
The settings page now exports/imports “playlist backups” instead of the old
+5 -1
View File
@@ -272,7 +272,11 @@ and sidecar state in localStorage:
`PwaXtreamDataSource` and `PwaCollectionStorage` reach these keys through the
`USER_DATA_STORAGE` token from `@iptvnator/services` (default:
`localStorage`), never through `localStorage` directly, so a deployment can
substitute a server-synced storage.
substitute a server-synced storage. The key names are shared as
`XTREAM_USER_DATA_KEYS` with the playlist backup, which exports and restores
the favorites, recent items, positions and snapshots through the same token
(`PwaXtreamBackupStateService`; see
[backup/restore](./playlist-backup-restore.md#browser-runtime)).
Entries should include a content snapshot when the item is added. Global
collection routes and the dashboard can then restore titles, posters, content
+6
View File
@@ -432,6 +432,12 @@ Detail metadata rule:
[Embedded Inline Playback](./embedded-inline-playback.md). It runs before
TMDB enrichment, which keeps a non-empty provider rating and release date,
so a surviving stand-in would also block the TMDB value.
- It also runs AFTER enrichment: the series view re-normalizes the store item
on every render, so it carries every `tmdb_*` field over. The key list
(`TMDB_ENRICHMENT_KEYS`) is typed as a `Record` over `StalkerVodInfo`'s
`tmdb_*` keys, so a new enrichment field fails to compile until it is listed
there instead of silently vanishing from the view (as the genre and country
Discover chips once did).
## Stalker Identity Policy
@@ -832,8 +832,10 @@ Contracts worth keeping:
credits and no recommendations carries none of them and reads as
un-enriched. Since the column is never overwritten, getting this wrong is
unfixable after the fact — a real provider date arriving later cannot
correct it. The same marker is what `trustedReleaseYear` (the
recommendations exclusion index) must consult if it ever reads this column.
correct it. Stalker entries never reach this column, but the same marker
rides in their stored `info`, and `trustedReleaseYear` (the recommendations
exclusion index) reads it first: a date TMDB supplied does not gate the
exact tier. `createStalkerInfo()` keeps the marker through re-normalization.
- **The id is stored unvetted.** Every consumer reaches TMDB through
`TmdbEnrichmentService`, whose `detailsForProviderId` runs
`assessProviderId` and lets the title search take over when the years
+3
View File
@@ -878,6 +878,9 @@ Toolchain notes for the Electron 43 baseline:
Those dependencies require standalone PRs so their dedicated package,
worker, playback, and diagnostic-contract validation cannot be hidden by an
unrelated grouped update.
Electron majors are ignored: Electron 44 ships no `linux-armv7l` binary,
and the release still packages armv7l AppImage, DEB and Snap builds. Remove
the ignore together with those targets when 32-bit ARM support is dropped.
4. Electron 42 and newer download their development binary on the first
Electron command instead of during package `postinstall`, so `electron`
must not remain in pnpm's `onlyBuiltDependencies` allowlist. The first local
@@ -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,
@@ -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', () => {
@@ -188,20 +188,46 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
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),
// 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 }
: {}),
...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,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
@@ -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
@@ -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;
}
+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,
@@ -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;
}
+3
View File
@@ -62,6 +62,9 @@
"a11y:icon-buttons:test": "node --test tools/nx/check-icon-button-names.test.mjs",
"a11y:icon-buttons:check": "node tools/nx/check-icon-button-names.mjs",
"a11y:icon-buttons:validate": "pnpm run a11y:icon-buttons:test && pnpm run a11y:icon-buttons:check",
"ui:form-field-defaults:test": "node --test tools/nx/check-form-field-defaults.test.mjs",
"ui:form-field-defaults:check": "node tools/nx/check-form-field-defaults.mjs",
"ui:form-field-defaults:validate": "pnpm run ui:form-field-defaults:test && pnpm run ui:form-field-defaults:check",
"e2e:task-graphs:test": "node --test tools/nx/check-e2e-task-graphs.test.mjs",
"e2e:task-graphs:check": "node tools/nx/check-e2e-task-graphs.mjs",
"e2e:task-graphs:validate": "pnpm run e2e:task-graphs:test && pnpm run e2e:task-graphs:check",
+192
View File
@@ -0,0 +1,192 @@
import { execFileSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
/**
* The app's `<mat-form-field>` defaults (outline, dynamic subscript) are
* provided per component, not in the root injector: importing
* MAT_FORM_FIELD_DEFAULT_OPTIONS in app.config.ts loads
* `@angular/material/form-field` and, since Material 22.2, `@angular/forms`
* on startup, although every form field lives in a lazy chunk. A component
* that renders a form field without the provider silently falls back to the
* `fill` appearance, so this guard requires it on each such component, and
* allows the provider and the token nowhere else.
*/
export const DEFAULTS_FILE =
'libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts';
const TOKEN = 'MAT_FORM_FIELD_DEFAULT_OPTIONS';
const PROVIDER = 'provideFormFieldDefaults';
const SCANNED_PATHSPECS = ['apps/web/*.ts', 'libs/*.ts'];
function isScanned(file) {
return !/\.(spec|test)\.[cm]?[jt]s$/.test(file);
}
/** The object literal of an `@Component({...})` decorator, if `node` is one. */
function componentMetadata(node) {
if (
!ts.isDecorator(node) ||
!ts.isCallExpression(node.expression) ||
!ts.isIdentifier(node.expression.expression) ||
node.expression.expression.text !== 'Component'
) {
return undefined;
}
const [metadata] = node.expression.arguments;
return metadata && ts.isObjectLiteralExpression(metadata)
? metadata
: undefined;
}
function property(metadata, name) {
return metadata.properties.find(
(entry) =>
ts.isPropertyAssignment(entry) &&
(ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) &&
entry.name.text === name
)?.initializer;
}
/** Literal text of a quoted, backticked or interpolated template. */
function literalText(node) {
if (ts.isStringLiteralLike(node)) return node.text;
if (ts.isTemplateExpression(node)) {
return [
node.head.text,
...node.templateSpans.map((span) => span.literal.text),
].join('');
}
return undefined;
}
function isProviderCall(node) {
return (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === PROVIDER
);
}
/**
* Findings for one TypeScript source. `readTemplate` resolves a
* `templateUrl` relative to the component and returns its text.
*/
export async function checkComponentSource(file, source, readTemplate) {
const sourceFile = ts.createSourceFile(
file,
source,
ts.ScriptTarget.Latest,
true
);
const lineOf = (node) =>
sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
.line + 1;
const findings = [];
const components = [];
const componentProviderCalls = new Set();
const visit = (node) => {
const metadata = componentMetadata(node);
if (metadata) {
const providers = property(metadata, 'providers');
const calls =
providers && ts.isArrayLiteralExpression(providers)
? providers.elements.filter(isProviderCall)
: [];
calls.forEach((call) => componentProviderCalls.add(call));
components.push({ node, metadata, provides: calls.length > 0 });
}
if (ts.isIdentifier(node) && node.text === TOKEN) {
if (file !== DEFAULTS_FILE) {
findings.push({
file,
line: lineOf(node),
reason: `uses ${TOKEN}; provide the app defaults with ${PROVIDER}() from @iptvnator/ui/components/form-field-defaults`,
});
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
const visitCalls = (node) => {
if (isProviderCall(node) && !componentProviderCalls.has(node)) {
findings.push({
file,
line: lineOf(node),
reason: `calls ${PROVIDER}() outside a component's providers; a root or route provider loads Material's form field on startup`,
});
}
ts.forEachChild(node, visitCalls);
};
visitCalls(sourceFile);
for (const { node, metadata, provides } of components) {
const inline = property(metadata, 'template');
const url = property(metadata, 'templateUrl');
let template = inline ? literalText(inline) : undefined;
if (template === undefined && url && ts.isStringLiteralLike(url)) {
template = await readTemplate(
path.posix.join(path.posix.dirname(file), url.text)
);
}
if (provides || !/<mat-form-field\b/.test(template ?? '')) continue;
findings.push({
file,
line: lineOf(node),
reason: `renders <mat-form-field> without providers: [${PROVIDER}()]`,
});
}
return findings.sort((a, b) => a.line - b.line);
}
/** Tracked renderer TypeScript files the guard reads, at any depth. */
export function listScannedFiles(rootDir) {
// No shell: `cmd.exe` treats single quotes as literal characters, so a
// POSIX-quoted pathspec reaches git intact on Windows and matches nothing.
return execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], {
cwd: rootDir,
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024,
})
.trim()
.split('\n')
.filter(Boolean)
.filter(isScanned);
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) ===
path.resolve(fileURLToPath(import.meta.url));
if (isMain) {
const rootDir = process.cwd();
const files = listScannedFiles(rootDir);
const readTemplate = (file) => readFile(path.join(rootDir, file), 'utf8');
const findings = [];
for (const file of files) {
const source = await readFile(path.join(rootDir, file), 'utf8');
findings.push(
...(await checkComponentSource(file, source, readTemplate))
);
}
if (findings.length > 0) {
console.error(
'Form-field defaults must be provided per component, not in the root injector:'
);
for (const { file, line, reason } of findings) {
console.error(`- ${file}:${line} ${reason}`);
}
process.exitCode = 1;
} else {
console.log(
`Checked ${files.length} renderer files; every <mat-form-field> component provides the app defaults.`
);
}
}
+200
View File
@@ -0,0 +1,200 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import {
DEFAULTS_FILE,
checkComponentSource,
listScannedFiles,
} from './check-form-field-defaults.mjs';
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..'
);
const templates = {
'libs/a/field.component.html':
'<mat-form-field><input matInput /></mat-form-field>',
'libs/a/plain.component.html': '<button mat-button>Save</button>',
};
const readTemplate = async (file) => templates[file];
const check = async (file, source) =>
(await checkComponentSource(file, source, readTemplate)).map(
({ line, reason }) => ({ line, reason })
);
test('accepts a templateUrl form field with the provider', async () => {
const source = [
'@Component({',
" selector: 'app-field',",
' providers: [provideFormFieldDefaults()],',
" templateUrl: './field.component.html',",
'})',
].join('\n');
assert.deepEqual(await check('libs/a/field.component.ts', source), []);
});
test('accepts the provider next to other providers', async () => {
const source = [
'@Component({',
' providers: [DatePipe, provideFormFieldDefaults()],',
" templateUrl: './field.component.html',",
'})',
].join('\n');
assert.deepEqual(await check('libs/a/field.component.ts', source), []);
});
test('reports a templateUrl form field without the provider', async () => {
const source = [
'@Component({',
" selector: 'app-field',",
" templateUrl: './field.component.html',",
'})',
].join('\n');
assert.deepEqual(await check('libs/a/field.component.ts', source), [
{
line: 1,
reason: 'renders <mat-form-field> without providers: [provideFormFieldDefaults()]',
},
]);
});
test('reports an inline template form field without the provider', async () => {
const source = [
'@Component({',
' template: `',
' <mat-form-field appearance="outline"></mat-form-field>',
' `,',
'})',
].join('\n');
assert.equal((await check('libs/a/inline.component.ts', source)).length, 1);
});
test('reads quoted inline templates', async () => {
const source = [
'@Component({',
" template: '<mat-form-field></mat-form-field>',",
'})',
'class Single {}',
'@Component({',
' template: "<mat-form-field></mat-form-field>",',
'})',
'class Double {}',
].join('\n');
assert.deepEqual(
(await check('libs/a/quoted.component.ts', source)).map(
({ line }) => line
),
[1, 5]
);
});
test('checks each component of a file on its own providers', async () => {
const source = [
'@Component({',
' providers: [provideFormFieldDefaults()],',
" templateUrl: './field.component.html',",
'})',
'class WithDefaults {}',
'@Component({',
" templateUrl: './field.component.html',",
'})',
'class WithoutDefaults {}',
].join('\n');
assert.deepEqual(await check('libs/a/field.component.ts', source), [
{
line: 6,
reason: 'renders <mat-form-field> without providers: [provideFormFieldDefaults()]',
},
]);
});
test('finds the provider after an earlier array-valued provider', async () => {
const source = [
'@Component({',
' providers: [',
' { provide: LIST, useValue: [] },',
' provideFormFieldDefaults(),',
' ],',
" templateUrl: './field.component.html',",
'})',
].join('\n');
assert.deepEqual(await check('libs/a/field.component.ts', source), []);
});
test('reports the provider outside a component', async () => {
const appConfig = [
'export const appConfig = {',
' providers: [provideRouter(routes), provideFormFieldDefaults()],',
'};',
].join('\n');
const routes = [
'export const routes = [',
" { path: 'settings', providers: [provideFormFieldDefaults()] },",
'];',
].join('\n');
assert.deepEqual(await check('apps/web/src/app/app.config.ts', appConfig), [
{
line: 2,
reason: "calls provideFormFieldDefaults() outside a component's providers; a root or route provider loads Material's form field on startup",
},
]);
assert.equal(
(await check('apps/web/src/app/app.routes.ts', routes)).length,
1
);
});
test('ignores templates without a form field', async () => {
const source = [
'@Component({',
" templateUrl: './plain.component.html',",
'})',
'@Component({ template: `<mat-icon>add</mat-icon>` })',
].join('\n');
assert.deepEqual(await check('libs/a/plain.component.ts', source), []);
});
test('reports the token outside the shared provider', async () => {
const source = [
"import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';",
'export const appConfig = {',
' providers: [{ provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: {} }],',
'};',
].join('\n');
assert.deepEqual(
(await check('apps/web/src/app/app.config.ts', source)).map(
({ line }) => line
),
[1, 3]
);
assert.deepEqual(await check(DEFAULTS_FILE, source), []);
});
test('the shared provider supplies the outline, dynamic defaults', async () => {
const source = await readFile(path.join(repoRoot, DEFAULTS_FILE), 'utf8');
assert.match(source, /appearance: 'outline'/);
assert.match(source, /subscriptSizing: 'dynamic'/);
assert.match(source, /provide: MAT_FORM_FIELD_DEFAULT_OPTIONS/);
});
test('scans renderer TypeScript and skips specs', () => {
const files = listScannedFiles(repoRoot);
assert.ok(files.includes(DEFAULTS_FILE));
assert.ok(files.includes('apps/web/src/app/app.config.ts'));
assert.ok(files.every((file) => !/\.spec\.ts$/.test(file)));
});
+1 -1
View File
@@ -3,7 +3,7 @@
"journeys": {
"launch": {
"renderer.initialBytes": {
"value": 1545931,
"value": 1484459,
"unit": "bytes",
"slack": 4096,
"updatedAt": "2026-10-13",
+3
View File
@@ -105,6 +105,9 @@
"@iptvnator/ui/components/confirm-dialog": [
"libs/ui/components/src/lib/confirm-dialog/dialog.service.ts"
],
"@iptvnator/ui/components/form-field-defaults": [
"libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts"
],
"@iptvnator/ui/components/parental-lock-pin-dialog": [
"libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts"
],