mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
commit
0622794253
52 files changed
+1934
-118
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
@@ -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: [
|
||||
|
||||
+20
@@ -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();
|
||||
|
||||
|
||||
+2
@@ -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: [
|
||||
|
||||
+2
-1
@@ -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.
|
||||
*
|
||||
|
||||
+64
@@ -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. */
|
||||
|
||||
+58
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
+2
@@ -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,
|
||||
};
|
||||
}
|
||||
+2
@@ -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,
|
||||
|
||||
+39
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
"journeys": {
|
||||
"launch": {
|
||||
"renderer.initialBytes": {
|
||||
"value": 1545931,
|
||||
"value": 1484459,
|
||||
"unit": "bytes",
|
||||
"slack": 4096,
|
||||
"updatedAt": "2026-10-13",
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user