Merge origin/master into claude/design-handoff-prompt-d0aee4

This commit is contained in:
4gray committed 2026-10-13 21:54:05 +02:00
commit 21fa6d0e78
39 files changed
+713 -39

No files matched your search

@@ -0,0 +1,7 @@
---
type: perf
area: performance
---
The app loads less code at startup: the text field component and its form
helpers now load with the screens that show a form instead of with the app.
@@ -0,0 +1,6 @@
---
type: fix
area: stalker
---
Stalker series details now keep their clickable genre and country chips after TMDB fills in the details, so you can jump to Discover from them as you can for movies.
+8 -4
View File
@@ -1,10 +1,11 @@
# Every Dependabot PR triggers the full pipeline (~15 jobs), so version
# updates are batched weekly. Nx minor+patch updates use a dedicated group so
# official packages move together; Nx majors are manual coordinated migrations.
# Native packaging, parser, and version-locked playback dependencies stay in
# standalone PRs; other minor+patch updates are grouped per ecosystem. Security
# updates are separate and are not limited by this schedule; CI enforces complete
# Nx lockstep.
# Electron majors are held while Linux armv7l packages ship: Electron 44
# dropped the linux-armv7l binary. Native packaging, parser, and
# version-locked playback dependencies stay in standalone PRs; other
# minor+patch updates are grouped per ecosystem. Security updates are separate
# and are not limited by this schedule; CI enforces complete Nx lockstep.
version: 2
updates:
- package-ecosystem: npm
@@ -21,6 +22,9 @@ updates:
- dependency-name: '@nx/*'
update-types:
- version-update:semver-major
- dependency-name: electron
update-types:
- version-update:semver-major
groups:
nx-version-updates:
applies-to: version-updates
+6
View File
@@ -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.
-5
View File
@@ -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' },
},
],
};
@@ -20,6 +20,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { UpdateChannelOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
interface SettingsUpdateStatusChip {
readonly labelKey: string;
@@ -28,6 +29,7 @@ interface SettingsUpdateStatusChip {
@Component({
selector: 'app-settings-about-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
MatFormFieldModule,
@@ -23,9 +23,11 @@ import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
import { TranslateModule } from '@ngx-translate/core';
import { EpgViewModeOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-epg-section',
providers: [provideFormFieldDefaults()],
imports: [
EpgSourceStatusComponent,
MatButtonModule,
@@ -20,9 +20,11 @@ import {
ThemeOption,
} from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-general-section',
providers: [provideFormFieldDefaults()],
imports: [
CommonModule,
MatFormFieldModule,
@@ -9,9 +9,11 @@ import {
} from '@angular/material/slide-toggle';
import { TranslateModule } from '@ngx-translate/core';
import { ParentalLockRelockMinutes } from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-parental-lock-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
MatFormFieldModule,
@@ -22,9 +22,11 @@ import {
} from '@iptvnator/shared/interfaces';
import { SettingsPlayerOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-playback-section',
providers: [provideFormFieldDefaults()],
imports: [
CommonModule,
MatButtonModule,
@@ -15,9 +15,11 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { QRCodeComponent } from 'angularx-qrcode';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-remote-control-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
MatFormFieldModule,
@@ -17,11 +17,13 @@ import { TranslateModule } from '@ngx-translate/core';
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
import type { TmdbCacheStats } from '@iptvnator/shared/interfaces';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
@Component({
selector: 'app-settings-tmdb-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
MatFormFieldModule,
+3 -2
View File
@@ -309,8 +309,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),
));
@@ -1064,6 +1064,13 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`.
## Forms
Every `<mat-form-field>` is outlined with a dynamic subscript. A component
that renders one adds `providers: [provideFormFieldDefaults()]`
(`@iptvnator/ui/components/form-field-defaults`); the root injector does not
provide the defaults, because the token import there would load Material's
form field and `@angular/forms` on startup. `pnpm run
ui:form-field-defaults:validate` (in CI) reports a missing provider.
The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one
vocabulary, so a field reads the same wherever it appears:
+6
View File
@@ -432,6 +432,12 @@ Detail metadata rule:
[Embedded Inline Playback](./embedded-inline-playback.md). It runs before
TMDB enrichment, which keeps a non-empty provider rating and release date,
so a surviving stand-in would also block the TMDB value.
- It also runs AFTER enrichment: the series view re-normalizes the store item
on every render, so it carries every `tmdb_*` field over. The key list
(`TMDB_ENRICHMENT_KEYS`) is typed as a `Record` over `StalkerVodInfo`'s
`tmdb_*` keys, so a new enrichment field fails to compile until it is listed
there instead of silently vanishing from the view (as the genre and country
Discover chips once did).
## Stalker Identity Policy
@@ -832,8 +832,10 @@ Contracts worth keeping:
credits and no recommendations carries none of them and reads as
un-enriched. Since the column is never overwritten, getting this wrong is
unfixable after the fact — a real provider date arriving later cannot
correct it. The same marker is what `trustedReleaseYear` (the
recommendations exclusion index) must consult if it ever reads this column.
correct it. Stalker entries never reach this column, but the same marker
rides in their stored `info`, and `trustedReleaseYear` (the recommendations
exclusion index) reads it first: a date TMDB supplied does not gate the
exact tier. `createStalkerInfo()` keeps the marker through re-normalization.
- **The id is stored unvetted.** Every consumer reaches TMDB through
`TmdbEnrichmentService`, whose `detailsForProviderId` runs
`assessProviderId` and lets the title search take over when the years
+3
View File
@@ -878,6 +878,9 @@ Toolchain notes for the Electron 43 baseline:
Those dependencies require standalone PRs so their dedicated package,
worker, playback, and diagnostic-contract validation cannot be hidden by an
unrelated grouped update.
Electron majors are ignored: Electron 44 ships no `linux-armv7l` binary,
and the release still packages armv7l AppImage, DEB and Snap builds. Remove
the ignore together with those targets when 32-bit ARM support is dropped.
4. Electron 42 and newer download their development binary on the first
Electron command instead of during package `postinstall`, so `electron`
must not remain in pnpm's `onlyBuiltDependencies` allowlist. The first local
@@ -20,6 +20,7 @@ import {
ProviderImportKind,
SOURCE_TYPE_ICONS,
} from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
interface CandidateSummaryRow {
labelKey: string;
@@ -145,6 +146,7 @@ function maskUrlQueryPasswords(url: string): string {
*/
@Component({
selector: 'app-auto-import',
providers: [provideFormFieldDefaults()],
templateUrl: './auto-import.component.html',
styleUrl: './auto-import.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -50,6 +50,7 @@ import {
toStalkerImportFeedback,
toStalkerPlaylistIdentityFields,
} from './stalker-import-identity';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
/**
* A MAC and the device IDs that belong to exactly it. Kept together because
@@ -75,6 +76,7 @@ interface StalkerSettledIdentity {
TranslatePipe,
],
selector: 'app-stalker-portal-import',
providers: [provideFormFieldDefaults()],
templateUrl: './stalker-portal-import.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
@@ -12,9 +12,11 @@ import {
} from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-text-import',
providers: [provideFormFieldDefaults()],
templateUrl: './text-import.component.html',
styleUrls: ['./text-import.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -15,9 +15,11 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-url-upload',
providers: [provideFormFieldDefaults()],
templateUrl: './url-upload.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
@@ -228,6 +228,26 @@ describe('XtreamCodeImportComponent form', () => {
);
}
it('outlines its fields with a dynamic subscript without a root provider', () => {
// The root injector no longer provides MAT_FORM_FIELD_DEFAULT_OPTIONS
// (it pulled Material's form field and @angular/forms into startup);
// the form carries provideFormFieldDefaults() itself.
const { root } = render();
const fields = Array.from(root.querySelectorAll('mat-form-field'));
expect(fields.length).toBeGreaterThan(0);
for (const field of fields) {
expect(field.classList).toContain(
'mat-form-field-appearance-outline'
);
expect(
field.querySelector(
'.mat-mdc-form-field-subscript-dynamic-size'
)
).not.toBeNull();
}
});
it('labels the name field like the other add-source forms', () => {
const { root } = render();
@@ -30,6 +30,7 @@ import {
normalizeXtreamServerUrl,
Playlist,
} from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
function xtreamServerUrlValidator(
control: AbstractControl
@@ -59,6 +60,7 @@ function xtreamServerUrlValidator(
TranslatePipe,
],
selector: 'app-xtream-code-import',
providers: [provideFormFieldDefaults()],
templateUrl: './xtream-code-import.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
@@ -62,6 +62,7 @@ import {
STALKER_PLAYLIST_CONNECTION_EDITOR,
STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS,
} from './stalker-playlist-connection-editor.token';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
type DesktopFileSaveBridge = Pick<
typeof window.electron,
@@ -165,7 +166,7 @@ type DesktopFileSaveBridge = Pick<
}
`,
],
providers: [DatePipe],
providers: [provideFormFieldDefaults(), DatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ClipboardModule,
@@ -253,44 +253,71 @@ describe('stalker-vod.utils regressions', () => {
});
it('preserves TMDB enrichment fields through info re-normalization', () => {
const tmdbCast = [{ name: 'Karl Urban', profileUrl: null }];
// Detail views re-normalize the selected item on every render, so
// every field `mergeStalkerInfoWithTmdb` can set has to survive here
const tmdbCast = [{ name: 'Rowan Quill', profileUrl: null }];
const tmdbRecommendations = [
{ tmdbId: 1, title: 'Invincible', year: 2021, posterUrl: null },
{ tmdbId: 1, title: 'Harbor Static', year: 2021, posterUrl: null },
];
const tmdbGenres = [{ id: 18, name: 'Drama' }];
const tmdbCountries = [{ code: 'NO', name: 'Norway' }];
const info = createStalkerInfo({
id: '7',
info: {
name: 'The Boys s05',
name: 'Lantern Coast s05',
movie_image: 'http://portal/poster.jpg',
description: 'Plot',
actors: 'Karl Urban',
actors: 'Rowan Quill',
director: '',
releasedate: '2026',
genre: 'Action',
releasedate: '2026-03-01',
genre: 'Drama',
rating_imdb: '',
rating_kinopoisk: '8.1',
tmdb_id: 4242,
tmdb_cast: tmdbCast,
tmdb_directors: [
{
name: 'Eric Kripke',
name: 'Mara Tollen',
profileUrl: null,
tmdbPersonId: 1216630,
},
],
tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/boys.jpg',
tmdb_status: 'returning',
tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/coast.jpg',
tmdb_trailer: 'abc123def',
tmdb_recommendations: tmdbRecommendations,
tmdb_genres: tmdbGenres,
tmdb_countries: tmdbCountries,
tmdb_media_type: 'tv',
tmdb_supplied_release_date: true,
},
});
expect(info.tmdb_id).toBe(4242);
expect(info.tmdb_cast).toEqual(tmdbCast);
expect(info.tmdb_directors?.[0]?.name).toBe('Eric Kripke');
expect(info.tmdb_directors?.[0]?.name).toBe('Mara Tollen');
expect(info.tmdb_status).toBe('returning');
expect(info.tmdb_backdrop).toBe(
'https://image.tmdb.org/t/p/w1280/boys.jpg'
'https://image.tmdb.org/t/p/w1280/coast.jpg'
);
expect(info.tmdb_trailer).toBe('abc123def');
expect(info.tmdb_recommendations).toEqual(tmdbRecommendations);
expect(info.tmdb_genres).toEqual(tmdbGenres);
expect(info.tmdb_countries).toEqual(tmdbCountries);
expect(info.tmdb_media_type).toBe('tv');
expect(info.tmdb_supplied_release_date).toBe(true);
});
it('adds no TMDB fields to a row that was never enriched', () => {
const info = createStalkerInfo({
id: '8',
info: { name: 'Unenriched Title', releasedate: '2019' },
});
expect(
Object.keys(info).filter((key) => key.startsWith('tmdb_'))
).toEqual([]);
});
describe('temporary-link flags survive normalization', () => {
@@ -188,20 +188,46 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
genre: text(info.genre, item.genre, item.genres_str),
rating_imdb: rating(info.rating_imdb, item.rating_imdb),
rating_kinopoisk: rating(info.rating_kinopoisk, item.rating_kinopoisk),
// TMDB enrichment fields must survive re-normalization — detail
// views normalize the selected item on every render
...(info.tmdb_id ? { tmdb_id: info.tmdb_id } : {}),
...(info.tmdb_cast ? { tmdb_cast: info.tmdb_cast } : {}),
...(info.tmdb_directors ? { tmdb_directors: info.tmdb_directors } : {}),
...(info.tmdb_status ? { tmdb_status: info.tmdb_status } : {}),
...(info.tmdb_backdrop ? { tmdb_backdrop: info.tmdb_backdrop } : {}),
...(info.tmdb_trailer ? { tmdb_trailer: info.tmdb_trailer } : {}),
...(info.tmdb_recommendations
? { tmdb_recommendations: info.tmdb_recommendations }
: {}),
...pickTmdbEnrichment(info),
};
}
type TmdbEnrichmentKey = Extract<keyof StalkerVodInfo, `tmdb_${string}`>;
/**
* Every TMDB enrichment field of {@link StalkerVodInfo}. A `Record` over the
* `tmdb_*` keys, so adding one to the interface fails to compile until it is
* listed here: detail views re-normalize the selected item on every render,
* and a field this list missed would vanish right after enrichment.
*/
const TMDB_ENRICHMENT_KEYS: Record<TmdbEnrichmentKey, true> = {
tmdb_id: true,
tmdb_cast: true,
tmdb_directors: true,
tmdb_status: true,
tmdb_backdrop: true,
tmdb_trailer: true,
tmdb_recommendations: true,
tmdb_genres: true,
tmdb_countries: true,
tmdb_media_type: true,
tmdb_supplied_release_date: true,
};
function pickTmdbEnrichment(
info: Partial<StalkerVodInfo>
): Partial<Pick<StalkerVodInfo, TmdbEnrichmentKey>> {
const picked: Record<string, unknown> = {};
for (const key of Object.keys(
TMDB_ENRICHMENT_KEYS
) as TmdbEnrichmentKey[]) {
if (info[key]) {
picked[key] = info[key];
}
}
return picked;
}
/**
* Narrows a raw portal row to the fields the detail/playback flows need.
*
@@ -0,0 +1,64 @@
import { ComponentFixture } from '@angular/core/testing';
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
import { createStalkerSeriesViewHarness } from './stalker-series-view.test-harness';
/**
* The view re-normalizes the selected item on every render, so the TMDB
* genre and country facets the enrichment merged into the store item have
* to survive that pass to become clickable Discover chips.
*/
describe('StalkerSeriesViewComponent TMDB facet chips', () => {
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
const { selectedItem, createFixture } = createStalkerSeriesViewHarness(() =>
jest.fn()
);
async function stabilize(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
}
function facetChipLabels(): string[] {
return Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll(
'button.meta-chip__facet'
)
).map((chip) => chip.textContent?.trim() ?? '');
}
beforeEach(async () => {
fixture = await createFixture();
});
afterEach(() => {
fixture?.destroy();
});
it('renders the enriched genres and countries as Discover chips', async () => {
await stabilize();
// Enrichment patches the store item after selection
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
description: 'Series description',
movie_image: 'poster.jpg',
genre: 'Drama',
tmdb_id: 777,
tmdb_genres: [
{ id: 18, name: 'Drama' },
{ id: 9648, name: 'Mystery' },
],
tmdb_countries: [{ code: 'NO', name: 'Norway' }],
tmdb_media_type: 'tv',
},
} as never);
await stabilize();
expect(facetChipLabels()).toEqual(
expect.arrayContaining(['Drama', 'Mystery', 'Norway'])
);
});
});
@@ -64,4 +64,6 @@ export interface StalkerVodInfo {
* this to send Discover clicks to the right media type.
*/
tmdb_media_type?: TmdbMediaType;
/** See `XtreamVodInfo.tmdb_supplied_release_date` — same contract */
tmdb_supplied_release_date?: boolean;
}
+1
View File
@@ -9,6 +9,7 @@ export * from './lib/confirm-dialog/confirm-dialog.component';
export * from './lib/confirm-dialog/dialog.service';
export * from './lib/content-hero/content-hero.component';
export * from './lib/expandable-text/expandable-text.component';
export * from './lib/form-field-defaults/form-field-defaults';
export * from './lib/external-playback-dock/external-playback-dock.component';
export * from './lib/portal-detail-shell/content-about.component';
export * from './lib/portal-detail-shell/detail-template.directives';
@@ -22,6 +22,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { debounceTime, from, Subject, switchMap } from 'rxjs';
import { provideFormFieldDefaults } from '../../form-field-defaults/form-field-defaults';
export interface EpgMappingDialogData {
channelKey: string;
@@ -39,6 +40,7 @@ const SEARCH_MIN_CHARS = 2;
@Component({
selector: 'app-epg-mapping-dialog',
providers: [provideFormFieldDefaults()],
templateUrl: './epg-mapping-dialog.component.html',
styleUrl: './epg-mapping-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -0,0 +1,25 @@
import { Provider } from '@angular/core';
import {
MAT_FORM_FIELD_DEFAULT_OPTIONS,
MatFormFieldDefaultOptions,
} from '@angular/material/form-field';
/** The app's outlined, dynamically sized form fields (see m3-theme.scss). */
export const APP_FORM_FIELD_DEFAULTS: MatFormFieldDefaultOptions = {
appearance: 'outline',
subscriptSizing: 'dynamic',
};
/**
* Provide the app's form-field defaults on a component that renders
* `<mat-form-field>`. They are not provided in the root injector: importing
* the token there loads `@angular/material/form-field` (and, since Material
* 22.2, `@angular/forms`) on startup, while every form field lives in a lazy
* chunk. `tools/nx/check-form-field-defaults.mjs` enforces this.
*/
export function provideFormFieldDefaults(): Provider {
return {
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
useValue: APP_FORM_FIELD_DEFAULTS,
};
}
@@ -27,6 +27,7 @@ import {
PARENTAL_LOCK_PIN_MAX_LENGTH,
PARENTAL_LOCK_PIN_MIN_LENGTH,
} from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '../form-field-defaults/form-field-defaults';
export type ParentalLockPinDialogMode = 'unlock' | 'set';
@@ -59,6 +60,7 @@ export interface ParentalLockPinDialogData {
*/
@Component({
selector: 'app-parental-lock-pin-dialog',
providers: [provideFormFieldDefaults()],
templateUrl: './parental-lock-pin-dialog.component.html',
styleUrl: './parental-lock-pin-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -885,6 +885,45 @@ describe('DashboardRecommendationsService', () => {
expect(service.items().map((item) => item.title)).toContain('Godzilla');
});
it('ignores a release date the TMDB merge supplied when excluding', async () => {
// The portal sent no date, so the stored one is the year of
// whatever TMDB matched. It must not gate the exact tier, or a
// namesake match would keep the watched title on the rail.
enrichMovie.mockResolvedValue({
recommendations: {
results: [
...recTitles
.slice(0, 5)
.map((title, i) => rec(100 + i, title)),
rec(200, 'Quarry Lights', 2014),
],
},
});
recentAll = [
...recentVod,
{
title: 'Quarry Lights',
type: 'movie',
stalker_item: {
id: '9',
category_id: 'vod',
info: {
name: 'Quarry Lights',
releasedate: '1954-11-03',
tmdb_supplied_release_date: true,
},
},
} as ActivityStub,
];
const service = createService();
await service.load();
expect(service.items().map((item) => item.title)).not.toContain(
'Quarry Lights'
);
});
it('excludes a watched title whose name ends in a year', async () => {
// "Blade Runner 2049" is a 2017 film; the 2049 belongs to the
// NAME, so it must not be read as a release year and used to
@@ -52,7 +52,10 @@ export type RecommendationCandidate = Omit<
* gating that key on a title-derived 2049 would fail to exclude the very
* film the user just watched (TMDB calls it 2017). Only Stalker rows
* carry a real date (`info.releasedate`); everything else yields `null`,
* which keeps the exact tier excluding unconditionally.
* which keeps the exact tier excluding unconditionally. So does a date the
* TMDB merge filled in itself (`tmdb_supplied_release_date`): it is the
* year of whatever TMDB matched, and a namesake match would otherwise stop
* the watched title from excluding itself.
*
* The base tier is different by construction — its key exists only
* because a trailing year was stripped off — so it keeps using that year.
@@ -63,6 +66,9 @@ export function trustedReleaseYear(
const info = (
item.stalker_item as { info?: Record<string, unknown> } | undefined
)?.info;
if (info?.['tmdb_supplied_release_date'] === true) {
return null;
}
const releaseDate = info?.['releasedate'];
return typeof releaseDate === 'string' ? extractYear(releaseDate) : null;
}
+3
View File
@@ -62,6 +62,9 @@
"a11y:icon-buttons:test": "node --test tools/nx/check-icon-button-names.test.mjs",
"a11y:icon-buttons:check": "node tools/nx/check-icon-button-names.mjs",
"a11y:icon-buttons:validate": "pnpm run a11y:icon-buttons:test && pnpm run a11y:icon-buttons:check",
"ui:form-field-defaults:test": "node --test tools/nx/check-form-field-defaults.test.mjs",
"ui:form-field-defaults:check": "node tools/nx/check-form-field-defaults.mjs",
"ui:form-field-defaults:validate": "pnpm run ui:form-field-defaults:test && pnpm run ui:form-field-defaults:check",
"e2e:task-graphs:test": "node --test tools/nx/check-e2e-task-graphs.test.mjs",
"e2e:task-graphs:check": "node tools/nx/check-e2e-task-graphs.mjs",
"e2e:task-graphs:validate": "pnpm run e2e:task-graphs:test && pnpm run e2e:task-graphs:check",
+192
View File
@@ -0,0 +1,192 @@
import { execFileSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
/**
* The app's `<mat-form-field>` defaults (outline, dynamic subscript) are
* provided per component, not in the root injector: importing
* MAT_FORM_FIELD_DEFAULT_OPTIONS in app.config.ts loads
* `@angular/material/form-field` and, since Material 22.2, `@angular/forms`
* on startup, although every form field lives in a lazy chunk. A component
* that renders a form field without the provider silently falls back to the
* `fill` appearance, so this guard requires it on each such component, and
* allows the provider and the token nowhere else.
*/
export const DEFAULTS_FILE =
'libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts';
const TOKEN = 'MAT_FORM_FIELD_DEFAULT_OPTIONS';
const PROVIDER = 'provideFormFieldDefaults';
const SCANNED_PATHSPECS = ['apps/web/*.ts', 'libs/*.ts'];
function isScanned(file) {
return !/\.(spec|test)\.[cm]?[jt]s$/.test(file);
}
/** The object literal of an `@Component({...})` decorator, if `node` is one. */
function componentMetadata(node) {
if (
!ts.isDecorator(node) ||
!ts.isCallExpression(node.expression) ||
!ts.isIdentifier(node.expression.expression) ||
node.expression.expression.text !== 'Component'
) {
return undefined;
}
const [metadata] = node.expression.arguments;
return metadata && ts.isObjectLiteralExpression(metadata)
? metadata
: undefined;
}
function property(metadata, name) {
return metadata.properties.find(
(entry) =>
ts.isPropertyAssignment(entry) &&
(ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) &&
entry.name.text === name
)?.initializer;
}
/** Literal text of a quoted, backticked or interpolated template. */
function literalText(node) {
if (ts.isStringLiteralLike(node)) return node.text;
if (ts.isTemplateExpression(node)) {
return [
node.head.text,
...node.templateSpans.map((span) => span.literal.text),
].join('');
}
return undefined;
}
function isProviderCall(node) {
return (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === PROVIDER
);
}
/**
* Findings for one TypeScript source. `readTemplate` resolves a
* `templateUrl` relative to the component and returns its text.
*/
export async function checkComponentSource(file, source, readTemplate) {
const sourceFile = ts.createSourceFile(
file,
source,
ts.ScriptTarget.Latest,
true
);
const lineOf = (node) =>
sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
.line + 1;
const findings = [];
const components = [];
const componentProviderCalls = new Set();
const visit = (node) => {
const metadata = componentMetadata(node);
if (metadata) {
const providers = property(metadata, 'providers');
const calls =
providers && ts.isArrayLiteralExpression(providers)
? providers.elements.filter(isProviderCall)
: [];
calls.forEach((call) => componentProviderCalls.add(call));
components.push({ node, metadata, provides: calls.length > 0 });
}
if (ts.isIdentifier(node) && node.text === TOKEN) {
if (file !== DEFAULTS_FILE) {
findings.push({
file,
line: lineOf(node),
reason: `uses ${TOKEN}; provide the app defaults with ${PROVIDER}() from @iptvnator/ui/components/form-field-defaults`,
});
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
const visitCalls = (node) => {
if (isProviderCall(node) && !componentProviderCalls.has(node)) {
findings.push({
file,
line: lineOf(node),
reason: `calls ${PROVIDER}() outside a component's providers; a root or route provider loads Material's form field on startup`,
});
}
ts.forEachChild(node, visitCalls);
};
visitCalls(sourceFile);
for (const { node, metadata, provides } of components) {
const inline = property(metadata, 'template');
const url = property(metadata, 'templateUrl');
let template = inline ? literalText(inline) : undefined;
if (template === undefined && url && ts.isStringLiteralLike(url)) {
template = await readTemplate(
path.posix.join(path.posix.dirname(file), url.text)
);
}
if (provides || !/<mat-form-field\b/.test(template ?? '')) continue;
findings.push({
file,
line: lineOf(node),
reason: `renders <mat-form-field> without providers: [${PROVIDER}()]`,
});
}
return findings.sort((a, b) => a.line - b.line);
}
/** Tracked renderer TypeScript files the guard reads, at any depth. */
export function listScannedFiles(rootDir) {
// No shell: `cmd.exe` treats single quotes as literal characters, so a
// POSIX-quoted pathspec reaches git intact on Windows and matches nothing.
return execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], {
cwd: rootDir,
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024,
})
.trim()
.split('\n')
.filter(Boolean)
.filter(isScanned);
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) ===
path.resolve(fileURLToPath(import.meta.url));
if (isMain) {
const rootDir = process.cwd();
const files = listScannedFiles(rootDir);
const readTemplate = (file) => readFile(path.join(rootDir, file), 'utf8');
const findings = [];
for (const file of files) {
const source = await readFile(path.join(rootDir, file), 'utf8');
findings.push(
...(await checkComponentSource(file, source, readTemplate))
);
}
if (findings.length > 0) {
console.error(
'Form-field defaults must be provided per component, not in the root injector:'
);
for (const { file, line, reason } of findings) {
console.error(`- ${file}:${line} ${reason}`);
}
process.exitCode = 1;
} else {
console.log(
`Checked ${files.length} renderer files; every <mat-form-field> component provides the app defaults.`
);
}
}
+200
View File
@@ -0,0 +1,200 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import {
DEFAULTS_FILE,
checkComponentSource,
listScannedFiles,
} from './check-form-field-defaults.mjs';
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..'
);
const templates = {
'libs/a/field.component.html':
'<mat-form-field><input matInput /></mat-form-field>',
'libs/a/plain.component.html': '<button mat-button>Save</button>',
};
const readTemplate = async (file) => templates[file];
const check = async (file, source) =>
(await checkComponentSource(file, source, readTemplate)).map(
({ line, reason }) => ({ line, reason })
);
test('accepts a templateUrl form field with the provider', async () => {
const source = [
'@Component({',
" selector: 'app-field',",
' providers: [provideFormFieldDefaults()],',
" templateUrl: './field.component.html',",
'})',
].join('\n');
assert.deepEqual(await check('libs/a/field.component.ts', source), []);
});
test('accepts the provider next to other providers', async () => {
const source = [
'@Component({',
' providers: [DatePipe, provideFormFieldDefaults()],',
" templateUrl: './field.component.html',",
'})',
].join('\n');
assert.deepEqual(await check('libs/a/field.component.ts', source), []);
});
test('reports a templateUrl form field without the provider', async () => {
const source = [
'@Component({',
" selector: 'app-field',",
" templateUrl: './field.component.html',",
'})',
].join('\n');
assert.deepEqual(await check('libs/a/field.component.ts', source), [
{
line: 1,
reason: 'renders <mat-form-field> without providers: [provideFormFieldDefaults()]',
},
]);
});
test('reports an inline template form field without the provider', async () => {
const source = [
'@Component({',
' template: `',
' <mat-form-field appearance="outline"></mat-form-field>',
' `,',
'})',
].join('\n');
assert.equal((await check('libs/a/inline.component.ts', source)).length, 1);
});
test('reads quoted inline templates', async () => {
const source = [
'@Component({',
" template: '<mat-form-field></mat-form-field>',",
'})',
'class Single {}',
'@Component({',
' template: "<mat-form-field></mat-form-field>",',
'})',
'class Double {}',
].join('\n');
assert.deepEqual(
(await check('libs/a/quoted.component.ts', source)).map(
({ line }) => line
),
[1, 5]
);
});
test('checks each component of a file on its own providers', async () => {
const source = [
'@Component({',
' providers: [provideFormFieldDefaults()],',
" templateUrl: './field.component.html',",
'})',
'class WithDefaults {}',
'@Component({',
" templateUrl: './field.component.html',",
'})',
'class WithoutDefaults {}',
].join('\n');
assert.deepEqual(await check('libs/a/field.component.ts', source), [
{
line: 6,
reason: 'renders <mat-form-field> without providers: [provideFormFieldDefaults()]',
},
]);
});
test('finds the provider after an earlier array-valued provider', async () => {
const source = [
'@Component({',
' providers: [',
' { provide: LIST, useValue: [] },',
' provideFormFieldDefaults(),',
' ],',
" templateUrl: './field.component.html',",
'})',
].join('\n');
assert.deepEqual(await check('libs/a/field.component.ts', source), []);
});
test('reports the provider outside a component', async () => {
const appConfig = [
'export const appConfig = {',
' providers: [provideRouter(routes), provideFormFieldDefaults()],',
'};',
].join('\n');
const routes = [
'export const routes = [',
" { path: 'settings', providers: [provideFormFieldDefaults()] },",
'];',
].join('\n');
assert.deepEqual(await check('apps/web/src/app/app.config.ts', appConfig), [
{
line: 2,
reason: "calls provideFormFieldDefaults() outside a component's providers; a root or route provider loads Material's form field on startup",
},
]);
assert.equal(
(await check('apps/web/src/app/app.routes.ts', routes)).length,
1
);
});
test('ignores templates without a form field', async () => {
const source = [
'@Component({',
" templateUrl: './plain.component.html',",
'})',
'@Component({ template: `<mat-icon>add</mat-icon>` })',
].join('\n');
assert.deepEqual(await check('libs/a/plain.component.ts', source), []);
});
test('reports the token outside the shared provider', async () => {
const source = [
"import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';",
'export const appConfig = {',
' providers: [{ provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: {} }],',
'};',
].join('\n');
assert.deepEqual(
(await check('apps/web/src/app/app.config.ts', source)).map(
({ line }) => line
),
[1, 3]
);
assert.deepEqual(await check(DEFAULTS_FILE, source), []);
});
test('the shared provider supplies the outline, dynamic defaults', async () => {
const source = await readFile(path.join(repoRoot, DEFAULTS_FILE), 'utf8');
assert.match(source, /appearance: 'outline'/);
assert.match(source, /subscriptSizing: 'dynamic'/);
assert.match(source, /provide: MAT_FORM_FIELD_DEFAULT_OPTIONS/);
});
test('scans renderer TypeScript and skips specs', () => {
const files = listScannedFiles(repoRoot);
assert.ok(files.includes(DEFAULTS_FILE));
assert.ok(files.includes('apps/web/src/app/app.config.ts'));
assert.ok(files.every((file) => !/\.spec\.ts$/.test(file)));
});
+3 -3
View File
@@ -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": {
+3
View File
@@ -105,6 +105,9 @@
"@iptvnator/ui/components/confirm-dialog": [
"libs/ui/components/src/lib/confirm-dialog/dialog.service.ts"
],
"@iptvnator/ui/components/form-field-defaults": [
"libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts"
],
"@iptvnator/ui/components/parental-lock-pin-dialog": [
"libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts"
],