From 8bf92f9d65d2a4231047be5f6a207e97d95da4de Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:07:49 +0200 Subject: [PATCH 1/4] fix(backup): export and restore Xtream user state in the PWA (#1905) * fix(backup): export and restore Xtream user state in the PWA Without an Electron bridge the backup wrote empty Xtream favorites, history and positions, and import only parked the state until the playlist was next opened. PwaXtreamBackupStateService reads and writes the four PWA maps through USER_DATA_STORAGE, so self-hosted server storage is covered as well. Export converts typed keys, resolves legacy ids only through their snapshot, and fails on an unreadable map instead of writing []. Import applies at once through applyAndConsume, remapped to the imported playlist id; a failed write keeps the snapshot parked for the store. Title snapshots travel in a new optional collectionItems field that older builds ignore. The storage key names are shared with the portal data source as XTREAM_USER_DATA_KEYS. Co-Authored-By: Claude Opus 5.5 * fix(backup): write PWA title snapshots before the Xtream lists A browser restore that failed after its first list write kept the pending state parked for the Xtream store, but that replay carries no title snapshots, so they were lost. Merge them first; a damaged cache is replaced, while a storage that cannot be read still fails the entry. Co-Authored-By: Claude Opus 5.5 * fix(backup): keep the artwork of PWA recently viewed items The browser build saves a backdrop with each view and shows it ahead of the title snapshot's, but the export dropped it, so a restore showed older artwork. Carry it as an optional backdropUrl on the archive's recently viewed items; the desktop build ignores it. Co-Authored-By: Claude Opus 5.5 * fix(xtream): keep view artwork in the PWA pending restore state A failed browser restore is replayed by PwaXtreamDataSource.restoreUserData, which rewrote recently viewed items without their backdropUrl; a refresh dropped it the same way in clearPlaylistContent. Both now carry it. Co-Authored-By: Claude Opus 5.5 * fix(backup): restore PWA Xtream lists before the snapshot cache Writing the optional title snapshots first could take the storage space the favorites, history or positions needed, failing a restore that fits without them. The lists now go first, as the snapshot-storage contract asks; after a failed list write the snapshots are still merged for the store's replay, unless storage is full. Co-Authored-By: Claude Opus 5.5 * fix(backup): pick the Xtream backup path by the SQLite data source The backup chose its Xtream path by the mere presence of window.electron, while XTREAM_DATA_SOURCE falls back to the browser storage whenever the bridge lacks the Xtream SQLite API. On such a partial or stale preload the export read the wrong store and the restore took the SQLite path. Both now follow RuntimeCapabilitiesService.supportsXtreamSqliteDataSource. Also casts the test helper's prototype instance through unknown, which the spec typecheck requires. Co-Authored-By: Claude Opus 5.5 * fix(xtream): restore PWA playback positions under the target playlist A PWA restore that is replayed by the Xtream store kept each position's exported playlistId. When the backup was imported under another id, the VOD details page rejected those positions as belonging to a different playlist. restoreUserData now stamps the playlist it restores into. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changes/backup-pwa-xtream-user-state.md | 6 + docs/architecture/playlist-backup-restore.md | 48 +- docs/architecture/pwa-self-hosted.md | 6 +- .../data-sources/pwa-collection-storage.ts | 11 +- .../pwa-xtream-collection-identity.spec.ts | 58 ++ .../data-sources/pwa-xtream-data-source.ts | 15 +- ...playlist-backup.service.pwa-xtream.spec.ts | 669 ++++++++++++++++++ .../playlist-backup.service.roundtrip.spec.ts | 92 ++- .../playlist-backup.service.test-helpers.ts | 25 + .../src/lib/playlist-backup.service.ts | 42 +- .../lib/pwa-xtream-backup-state.service.ts | 297 ++++++++ libs/services/src/lib/user-data-storage.ts | 12 + .../src/lib/playlist-backup.interface.ts | 23 + 13 files changed, 1223 insertions(+), 81 deletions(-) create mode 100644 .changes/backup-pwa-xtream-user-state.md create mode 100644 libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts create mode 100644 libs/services/src/lib/pwa-xtream-backup-state.service.ts diff --git a/.changes/backup-pwa-xtream-user-state.md b/.changes/backup-pwa-xtream-user-state.md new file mode 100644 index 000000000..a2e8acec6 --- /dev/null +++ b/.changes/backup-pwa-xtream-user-state.md @@ -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. diff --git a/docs/architecture/playlist-backup-restore.md b/docs/architecture/playlist-backup-restore.md index 0554de983..6e8014478 100644 --- a/docs/architecture/playlist-backup-restore.md +++ b/docs/architecture/playlist-backup-restore.md @@ -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 diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index ebf92c5eb..f055674bf 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -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 diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts index 59d3efec5..7ecee0678 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts @@ -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. */ diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-collection-identity.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-collection-identity.spec.ts index ef96a02d9..e88aa7578 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-collection-identity.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-collection-identity.spec.ts @@ -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', diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 2c8c8d380..af61c2906 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -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(), }) ); diff --git a/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts b/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts new file mode 100644 index 000000000..7a1599452 --- /dev/null +++ b/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts @@ -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 { + 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(); + 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 { + 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(); + 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), + }; + } +}); diff --git a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts index e518cc66f..53a5194e7 100644 --- a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts @@ -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)', diff --git a/libs/services/src/lib/playlist-backup.service.test-helpers.ts b/libs/services/src/lib/playlist-backup.service.test-helpers.ts index 4a48344cf..4db4ec481 100644 --- a/libs/services/src/lib/playlist-backup.service.test-helpers.ts +++ b/libs/services/src/lib/playlist-backup.service.test-helpers.ts @@ -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, }); diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index 3078285ea..85498b79b 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -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; async exportBackup(): Promise { @@ -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; - } } diff --git a/libs/services/src/lib/pwa-xtream-backup-state.service.ts b/libs/services/src/lib/pwa-xtream-backup-state.service.ts new file mode 100644 index 000000000..36287dbbd --- /dev/null +++ b/libs/services/src/lib/pwa-xtream-backup-state.service.ts @@ -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; +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 + * `":"`, 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(); + 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(); + 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 { + // 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 + ): 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 { + const byKey: Record = {}; + 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); +} diff --git a/libs/services/src/lib/user-data-storage.ts b/libs/services/src/lib/user-data-storage.ts index 78552b563..647c2eaba 100644 --- a/libs/services/src/lib/user-data-storage.ts +++ b/libs/services/src/lib/user-data-storage.ts @@ -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 diff --git a/libs/shared/interfaces/src/lib/playlist-backup.interface.ts b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts index 4ddcab746..8fcb6ee96 100644 --- a/libs/shared/interfaces/src/lib/playlist-backup.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts @@ -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 From 294d630156d6840c3ba07cef4d577276e2f66830 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:51:46 +0200 Subject: [PATCH 2/4] fix(stalker): keep TMDB genre and country chips on Stalker series (#1930) The series view re-normalizes the selected item through createStalkerInfo() on every render, and that mapping carried only seven hand-picked tmdb_* fields. tmdb_genres, tmdb_countries, tmdb_media_type and tmdb_supplied_release_date were dropped right after enrichment, so the clickable Discover chips never appeared on Stalker series. The carried keys now come from a Record over StalkerVodInfo's tmdb_* keys, so a new enrichment field fails to compile until it is listed. Saved Stalker entries now also keep tmdb_supplied_release_date, and the recommendations exclusion index (trustedReleaseYear) skips a date TMDB supplied, as the flag's contract requires. Co-authored-by: Claude Opus 5.5 --- .changes/stalker-series-discover-chips.md | 6 ++ docs/architecture/stalker-portal.md | 6 ++ docs/architecture/tmdb-metadata-enrichment.md | 6 +- .../src/lib/stalker-vod.utils.spec.ts | 47 +++++++++++--- .../data-access/src/lib/stalker-vod.utils.ts | 48 ++++++++++---- .../stalker-series-view.tmdb-facets.spec.ts | 64 +++++++++++++++++++ .../src/lib/stalker-vod-details.interface.ts | 2 + .../dashboard-recommendations.service.spec.ts | 39 +++++++++++ .../src/lib/dashboard-recommendations.util.ts | 8 ++- 9 files changed, 202 insertions(+), 24 deletions(-) create mode 100644 .changes/stalker-series-discover-chips.md create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-facets.spec.ts diff --git a/.changes/stalker-series-discover-chips.md b/.changes/stalker-series-discover-chips.md new file mode 100644 index 000000000..db6945f23 --- /dev/null +++ b/.changes/stalker-series-discover-chips.md @@ -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. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index c2aee3afc..90997a8e2 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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 diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 606eb72fa..3b2af24bd 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -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 diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts index 23dfe09cb..b0bc7563b 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts @@ -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', () => { diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts index f650aa371..7ec8dc9a8 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts @@ -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; + +/** + * 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 = { + 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 +): Partial> { + const picked: Record = {}; + 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. * diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-facets.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-facets.spec.ts new file mode 100644 index 000000000..f0a343a66 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-facets.spec.ts @@ -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; + const { selectedItem, createFixture } = createStalkerSeriesViewHarness(() => + jest.fn() + ); + + async function stabilize(): Promise { + 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']) + ); + }); +}); diff --git a/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts b/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts index 32c7d5aef..8a87d4258 100644 --- a/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts +++ b/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts @@ -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; } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts index 10602e866..9dd0d806a 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts @@ -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 diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts index a232249de..3aa628293 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts @@ -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 } | undefined )?.info; + if (info?.['tmdb_supplied_release_date'] === true) { + return null; + } const releaseDate = info?.['releasedate']; return typeof releaseDate === 'string' ? extractYear(releaseDate) : null; } From c51ef83b565fbe1f2802f736db8089eeda368d34 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:53:04 +0200 Subject: [PATCH 3/4] ci(deps): hold Electron at 43 while armv7l Linux packages ship (#1925) Electron 44 publishes no linux-armv7l binary, so the 44.x update fails the Linux packaging jobs on the armv7l AppImage, DEB and Snap targets. Ignore Electron majors in Dependabot until 32-bit ARM support is dropped deliberately; 43.x minor and patch updates still arrive. Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .github/dependabot.yml | 12 ++++++++---- docs/architecture/workspace-shell.md | 3 +++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 965668988..6b4853bf0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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 diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 58b09ed0c..8d7f837c0 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -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 From ec4b9286a7ff62b1751970e8d12e6e9e6bc83aa6 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:53:08 +0200 Subject: [PATCH 4/4] perf(web): provide form-field defaults per component, not at the root (#1928) * perf(web): provide form-field defaults per component, not at the root app.config.ts provided MAT_FORM_FIELD_DEFAULT_OPTIONS in the root injector. Importing the token kept @angular/material/form-field in main.js, and Material 22.2's form field imports @angular/forms/signals, which would add @angular/forms to startup as well (the reason the Angular 22.2 dependency update exceeds the initial-bytes ratchet). Every form field renders in a lazy chunk, so the outline + dynamic defaults now come from provideFormFieldDefaults() (@iptvnator/ui/components/form-field-defaults) on each component that renders . tools/nx/check-form-field-defaults.mjs (ui:form-field-defaults:validate, run in CI) reports a component that renders a form field without the provider, and any use of the token outside the shared provider. The Xtream import form test asserts outlined, dynamically sized fields with no root provider; without the component provider they render as fill. renderer.initialBytes: 1,596,201 -> 1,534,604 bytes (-61,597) on a local production build; the baseline is lowered to the measured value. Co-Authored-By: Claude Opus 5.5 * perf(web): cite #1928 as the initial-bytes baseline evidence Co-Authored-By: Claude Opus 5.5 * test(ui): check form-field providers per component on the TypeScript AST The guard matched templates and providers with regular expressions over the whole file: a quoted inline template went unread, one component's provider covered another in the same file, an earlier array-valued provider hid the call, and provideFormFieldDefaults() in app.config or a route passed although it loads Material's form field on startup again. It now reads each @Component decorator's own template and providers array, and reports any provideFormFieldDefaults() call outside one. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../performance-lazy-form-field-defaults.md | 7 + .github/workflows/ci.yml | 6 + apps/web/src/app/app.config.ts | 5 - .../settings-about-section.component.ts | 2 + .../settings-epg-section.component.ts | 2 + .../settings-general-section.component.ts | 2 + .../settings-parental-section.component.ts | 2 + .../settings-playback-section.component.ts | 2 + ...ttings-remote-control-section.component.ts | 2 + .../settings-tmdb-section.component.ts | 2 + apps/web/src/m3-theme.scss | 5 +- docs/architecture/iptvnator-ui-guidelines.md | 7 + .../lib/auto-import/auto-import.component.ts | 2 + .../stalker-portal-import.component.ts | 2 + .../lib/text-import/text-import.component.ts | 2 + .../lib/url-upload/url-upload.component.ts | 2 + .../xtream-code-import.component.spec.ts | 20 ++ .../xtream-code-import.component.ts | 2 + .../playlist-info/playlist-info.component.ts | 3 +- libs/ui/components/src/index.ts | 1 + .../epg-mapping-dialog.component.ts | 2 + .../form-field-defaults.ts | 25 +++ .../parental-lock-pin-dialog.component.ts | 2 + package.json | 3 + tools/nx/check-form-field-defaults.mjs | 192 +++++++++++++++++ tools/nx/check-form-field-defaults.test.mjs | 200 ++++++++++++++++++ tools/performance/journey-baselines.json | 6 +- tsconfig.base.json | 3 + 28 files changed, 500 insertions(+), 11 deletions(-) create mode 100644 .changes/performance-lazy-form-field-defaults.md create mode 100644 libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts create mode 100644 tools/nx/check-form-field-defaults.mjs create mode 100644 tools/nx/check-form-field-defaults.test.mjs diff --git a/.changes/performance-lazy-form-field-defaults.md b/.changes/performance-lazy-form-field-defaults.md new file mode 100644 index 000000000..d1ae58790 --- /dev/null +++ b/.changes/performance-lazy-form-field-defaults.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5a581c80..d25580c26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -430,6 +430,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. diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 6a5b072bb..27e447a27 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -9,7 +9,6 @@ import { OverlayContainer, } from '@angular/cdk/overlay'; import { ApplicationConfig, inject, importProvidersFrom } 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'; @@ -162,9 +161,5 @@ export const appConfig: ApplicationConfig = { ...provideParentalLockPrompt(), provideParentalLockPlaylistCleanup(), ...provideXtreamDataSource(), - { - provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, - useValue: { appearance: 'outline', subscriptSizing: 'dynamic' }, - }, ], }; diff --git a/apps/web/src/app/settings/settings-about-section.component.ts b/apps/web/src/app/settings/settings-about-section.component.ts index 511206c62..d48af2add 100644 --- a/apps/web/src/app/settings/settings-about-section.component.ts +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -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, diff --git a/apps/web/src/app/settings/settings-epg-section.component.ts b/apps/web/src/app/settings/settings-epg-section.component.ts index d4ec29556..431a94796 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.ts +++ b/apps/web/src/app/settings/settings-epg-section.component.ts @@ -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, diff --git a/apps/web/src/app/settings/settings-general-section.component.ts b/apps/web/src/app/settings/settings-general-section.component.ts index 7bb7b4f33..92582b33c 100644 --- a/apps/web/src/app/settings/settings-general-section.component.ts +++ b/apps/web/src/app/settings/settings-general-section.component.ts @@ -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, diff --git a/apps/web/src/app/settings/settings-parental-section.component.ts b/apps/web/src/app/settings/settings-parental-section.component.ts index 7e874c010..50ced0a82 100644 --- a/apps/web/src/app/settings/settings-parental-section.component.ts +++ b/apps/web/src/app/settings/settings-parental-section.component.ts @@ -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, diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts index 5fd306029..b806917b2 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -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, diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.ts b/apps/web/src/app/settings/settings-remote-control-section.component.ts index 3b69c0636..9e99ac088 100644 --- a/apps/web/src/app/settings/settings-remote-control-section.component.ts +++ b/apps/web/src/app/settings/settings-remote-control-section.component.ts @@ -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, diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.ts b/apps/web/src/app/settings/settings-tmdb-section.component.ts index 428893053..5a751e376 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.ts @@ -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, diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index acd62acd0..ccf773f13 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -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), )); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 5886a24ae..da7192356 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -1033,6 +1033,13 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`. ## Forms +Every `` 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: diff --git a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts index 0b86cb5af..4283d80fd 100644 --- a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts @@ -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, diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 1a19d0403..f4e93c8c2 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -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: [ diff --git a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts index f767cffb7..f5751e4f6 100644 --- a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts +++ b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts @@ -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, diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts index 59338b8bf..0df1ae15e 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts @@ -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: [ diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts index 72f4e8099..1c72b0334 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts @@ -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(); diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts index ca7c148f9..ac9d137a4 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts @@ -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: [ diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index 0f68e88ba..12e0399fe 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -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, diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index df0b14ba3..cd83984b6 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -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'; diff --git a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts index bc0e423c8..51d88ed55 100644 --- a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts @@ -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, diff --git a/libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts b/libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts new file mode 100644 index 000000000..c29c8996e --- /dev/null +++ b/libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts @@ -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 + * ``. 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, + }; +} diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts index 42fb878d9..486e1d8e8 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts @@ -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, diff --git a/package.json b/package.json index 023cb8546..8bd8d804f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tools/nx/check-form-field-defaults.mjs b/tools/nx/check-form-field-defaults.mjs new file mode 100644 index 000000000..624d66be9 --- /dev/null +++ b/tools/nx/check-form-field-defaults.mjs @@ -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 `` 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 || !/ 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 component provides the app defaults.` + ); + } +} diff --git a/tools/nx/check-form-field-defaults.test.mjs b/tools/nx/check-form-field-defaults.test.mjs new file mode 100644 index 000000000..b3301622a --- /dev/null +++ b/tools/nx/check-form-field-defaults.test.mjs @@ -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': + '', + 'libs/a/plain.component.html': '', +}; +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 without providers: [provideFormFieldDefaults()]', + }, + ]); +}); + +test('reports an inline template form field without the provider', async () => { + const source = [ + '@Component({', + ' template: `', + ' ', + ' `,', + '})', + ].join('\n'); + + assert.equal((await check('libs/a/inline.component.ts', source)).length, 1); +}); + +test('reads quoted inline templates', async () => { + const source = [ + '@Component({', + " template: '',", + '})', + 'class Single {}', + '@Component({', + ' template: "",', + '})', + '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 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: `add` })', + ].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))); +}); diff --git a/tools/performance/journey-baselines.json b/tools/performance/journey-baselines.json index ee8841485..d3814bfb3 100644 --- a/tools/performance/journey-baselines.json +++ b/tools/performance/journey-baselines.json @@ -3,11 +3,11 @@ "journeys": { "launch": { "renderer.initialBytes": { - "value": 1595777, + "value": 1534604, "unit": "bytes", "slack": 4096, - "updatedAt": "2026-10-07", - "evidencePr": 1810, + "updatedAt": "2026-10-13", + "evidencePr": 1928, "measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes" }, "renderer.ipcCallsToFirstCard": { diff --git a/tsconfig.base.json b/tsconfig.base.json index 01a5e5b29..de5cbeead 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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" ],