From ca301d53991979ee286290187f38a95bde67e0d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:28:57 +0200 Subject: [PATCH 1/8] chore(deps): bump the npm-minor-patch group with 8 updates (#1459) Bumps the npm-minor-patch group with 8 updates: | Package | From | To | | --- | --- | --- | | [hls.js](https://github.com/video-dev/hls.js) | `1.6.17` | `1.7.0` | | [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.66.0` | `8.67.0` | | [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.66.0` | `8.67.0` | | [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils) | `8.66.0` | `8.67.0` | | [esbuild](https://github.com/evanw/esbuild) | `0.28.1` | `0.28.2` | | [ng-mocks](https://github.com/help-me-mom/ng-mocks) | `14.16.1` | `14.17.1` | | [tsx](https://github.com/privatenumber/tsx) | `4.23.11` | `4.23.12` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.66.0` | `8.67.0` | Updates `hls.js` from 1.6.17 to 1.7.0 - [Release notes](https://github.com/video-dev/hls.js/releases) - [Changelog](https://github.com/video-dev/hls.js/blob/master/docs/release-process.md) - [Commits](https://github.com/video-dev/hls.js/compare/v1.6.17...v1.7.0) Updates `@typescript-eslint/eslint-plugin` from 8.66.0 to 8.67.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.67.0/packages/eslint-plugin) Updates `@typescript-eslint/parser` from 8.66.0 to 8.67.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.67.0/packages/parser) Updates `@typescript-eslint/utils` from 8.66.0 to 8.67.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.67.0/packages/utils) Updates `esbuild` from 0.28.1 to 0.28.2 - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md) - [Commits](https://github.com/evanw/esbuild/compare/v0.28.1...v0.28.2) Updates `ng-mocks` from 14.16.1 to 14.17.1 - [Release notes](https://github.com/help-me-mom/ng-mocks/releases) - [Changelog](https://github.com/help-me-mom/ng-mocks/blob/main/CHANGELOG.md) - [Commits](https://github.com/help-me-mom/ng-mocks/compare/v14.16.1...v14.17.1) Updates `tsx` from 4.23.11 to 4.23.12 - [Release notes](https://github.com/privatenumber/tsx/releases) - [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs) - [Commits](https://github.com/privatenumber/tsx/compare/v4.23.11...v4.23.12) Updates `typescript-eslint` from 8.66.0 to 8.67.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.67.0/packages/typescript-eslint) --- updated-dependencies: - dependency-name: hls.js dependency-version: 1.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@typescript-eslint/eslint-plugin" dependency-version: 8.67.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@typescript-eslint/parser" dependency-version: 8.67.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@typescript-eslint/utils" dependency-version: 8.67.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: esbuild dependency-version: 0.28.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: ng-mocks dependency-version: 14.17.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: tsx dependency-version: 4.23.12 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: typescript-eslint dependency-version: 8.67.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 16 +- pnpm-lock.yaml | 984 ++++++++++++++++++++++++++++++++++--------------- 2 files changed, 688 insertions(+), 312 deletions(-) diff --git a/package.json b/package.json index 5f0afd42d..2b19cab0e 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "electron-updater": "6.8.9", "epg-parser": "^0.5.0", "fix-path": "5.0.0", - "hls.js": "1.6.17", + "hls.js": "1.7.0", "iptv-playlist-parser": "github:4gray/iptv-playlist-parser#v0.15.2-iptvnator.2", "marked": "18.0.9", "mpegts.js": "1.8.1", @@ -184,9 +184,9 @@ "@types/mocha": "9.0.0", "@types/node": "20.19.9", "@types/video.js": "7.3.58", - "@typescript-eslint/eslint-plugin": "^8.66.0", - "@typescript-eslint/parser": "^8.66.0", - "@typescript-eslint/utils": "^8.66.0", + "@typescript-eslint/eslint-plugin": "^8.67.0", + "@typescript-eslint/parser": "^8.67.0", + "@typescript-eslint/utils": "^8.67.0", "angular-eslint": "21.4.0", "astro": "5.18.1", "cors": "2.8.6", @@ -194,7 +194,7 @@ "electron": "^43.3.0", "electron-builder": "^26.15.7", "electron-playwright-helpers": "1.8.2", - "esbuild": "0.28.1", + "esbuild": "0.28.2", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "2.32.0", @@ -211,7 +211,7 @@ "jest-util": "^30.4.1", "jsonc-eslint-parser": "^2.1.0", "material-design-icons-iconfont": "6.7.0", - "ng-mocks": "14.16.1", + "ng-mocks": "14.17.1", "nx": "22.7.8", "nx-electron": "22.0.0", "prettier": "^3.9.6", @@ -220,9 +220,9 @@ "ts-jest": "^29.4.12", "ts-node": "10.9.2", "tslib": "^2.8.1", - "tsx": "4.23.11", + "tsx": "4.23.12", "typescript": "5.9.3", - "typescript-eslint": "^8.66.0", + "typescript-eslint": "^8.67.0", "yaml": "2.9.0" }, "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 927ec5f25..7e5b65a31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,8 +127,8 @@ importers: specifier: 5.0.0 version: 5.0.0 hls.js: - specifier: 1.6.17 - version: 1.6.17 + specifier: 1.7.0 + version: 1.7.0 iptv-playlist-parser: specifier: github:4gray/iptv-playlist-parser#v0.15.2-iptvnator.2 version: https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/d547b6be8a44431c85fbcbcdefb41f28c0dadf6d @@ -180,19 +180,19 @@ importers: version: 21.3.1(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/eslint-plugin': specifier: 21.3.1 - version: 21.3.1(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + version: 21.3.1(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/eslint-plugin-template': specifier: 21.3.1 - version: 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + version: 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.67.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/schematics': specifier: 21.3.1 - version: 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + version: 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.67.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/template-parser': specifier: 21.3.1 version: 21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular/build': specifier: 21.2.19 - version: 21.2.19(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(typescript@5.9.3))(@angular/compiler@21.2.19)(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.19(@angular/animations@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/service-worker@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(chokidar@5.0.0)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.100.0)(tailwindcss@3.4.19(tsx@4.23.11)(yaml@2.9.0))(terser@5.49.2)(tslib@2.8.1)(tsx@4.23.11)(typescript@5.9.3)(yaml@2.9.0) + version: 21.2.19(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(typescript@5.9.3))(@angular/compiler@21.2.19)(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.19(@angular/animations@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/service-worker@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(chokidar@5.0.0)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.100.0)(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0))(terser@5.49.2)(tslib@2.8.1)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) '@angular/cli': specifier: 21.2.19 version: 21.2.19(@types/node@20.19.9)(chokidar@5.0.0) @@ -207,13 +207,13 @@ importers: version: 21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) '@astrojs/mdx': specifier: 4.3.13 - version: 4.3.13(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.11)(typescript@5.9.3)(yaml@2.9.0)) + version: 4.3.13(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0)) '@astrojs/sitemap': specifier: 3.7.3 version: 3.7.3 '@astrojs/tailwind': specifier: 6.0.2 - version: 6.0.2(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.11)(typescript@5.9.3)(yaml@2.9.0))(tailwindcss@3.4.19(tsx@4.23.11)(yaml@2.9.0))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + version: 6.0.2(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0))(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) '@electron/asar': specifier: 3.4.1 version: 3.4.1 @@ -240,7 +240,7 @@ importers: version: 5.3.0 '@ngrx/eslint-plugin': specifier: ^21.1.1 - version: 21.1.1(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3) + version: 21.1.1(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3) '@ngx-translate/core': specifier: 16.0.4 version: 16.0.4(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)) @@ -249,31 +249,31 @@ importers: version: 16.0.1(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)) '@nx/angular': specifier: 22.7.8 - version: 22.7.8(e8795a5656eb779e74dd501224407fda) + version: 22.7.8(d05e5b074e75040418244af5b15d2172) '@nx/devkit': specifier: 22.7.8 version: 22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) '@nx/esbuild': specifier: 22.7.8 - version: 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) + version: 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) '@nx/eslint': specifier: 22.7.8 - version: 22.7.8(19987e244c6e3d0a20d130ecb4380e79) + version: 22.7.8(5d8e116ecf28960b728f05bee433010d) '@nx/eslint-plugin': specifier: 22.7.8 - version: 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript-eslint/parser@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(typescript@5.9.3) + version: 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(typescript@5.9.3) '@nx/jest': specifier: 22.7.8 - version: 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3) + version: 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3) '@nx/js': specifier: 22.7.8 version: 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) '@nx/playwright': specifier: 22.7.8 - version: 22.7.8(38b9a7d3b8a3cfecba597fb22d827e8b) + version: 22.7.8(fd8f618a9324a70c524ebeba2216a705) '@nx/web': specifier: 22.7.8 - version: 22.7.8(6972877962196def71b9e579ef557689) + version: 22.7.8(13c5604b3d97b74c688c777b807087c9) '@nx/workspace': specifier: 22.7.8 version: 22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)) @@ -294,7 +294,7 @@ importers: version: 0.5.23 '@tailwindcss/typography': specifier: 0.5.20 - version: 0.5.20(tailwindcss@3.4.19(tsx@4.23.11)(yaml@2.9.0)) + version: 0.5.20(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0)) '@types/better-sqlite3': specifier: ^7.6.12 version: 7.6.13 @@ -317,20 +317,20 @@ importers: specifier: 7.3.58 version: 7.3.58 '@typescript-eslint/eslint-plugin': - specifier: ^8.66.0 - version: 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + specifier: ^8.67.0 + version: 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^8.66.0 - version: 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + specifier: ^8.67.0 + version: 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/utils': - specifier: ^8.66.0 - version: 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + specifier: ^8.67.0 + version: 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) angular-eslint: specifier: 21.4.0 - version: 21.4.0(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3) + version: 21.4.0(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3) astro: specifier: 5.18.1 - version: 5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.11)(typescript@5.9.3)(yaml@2.9.0) + version: 5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) cors: specifier: 2.8.6 version: 2.8.6 @@ -347,8 +347,8 @@ importers: specifier: 1.8.2 version: 1.8.2 esbuild: - specifier: 0.28.1 - version: 0.28.1 + specifier: 0.28.2 + version: 0.28.2 eslint: specifier: ^9.8.0 version: 9.39.2(jiti@1.21.7) @@ -357,7 +357,7 @@ importers: version: 10.1.8(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-import: specifier: 2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)) + version: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-playwright: specifier: ^1.6.2 version: 1.8.3(eslint@9.39.2(jiti@1.21.7)) @@ -378,7 +378,7 @@ importers: version: 3.2.0 jest: specifier: ^30.4.2 - version: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + version: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) jest-environment-jsdom: specifier: ^30.4.1 version: 30.4.1 @@ -387,7 +387,7 @@ importers: version: 30.4.1 jest-preset-angular: specifier: ~15.0.0 - version: 15.0.3(36d1d9397856c52c68162d7a1ca96a4c) + version: 15.0.3(e3b998715f9ba0e14eb4dd646f182898) jest-util: specifier: ^30.4.1 version: 30.4.1 @@ -398,14 +398,14 @@ importers: specifier: 6.7.0 version: 6.7.0 ng-mocks: - specifier: 14.16.1 - version: 14.16.1(b285834fe0000b6ade3305b52068a4d2) + specifier: 14.17.1 + version: 14.17.1(b285834fe0000b6ade3305b52068a4d2) nx: specifier: 22.7.8 version: 22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)) nx-electron: specifier: 22.0.0 - version: 22.0.0(patch_hash=f4bbde1778360c4c462b255bec47a337c0465d59cdcab14ecb601161f3467002)(@nx/devkit@22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))))(@nx/workspace@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(@swc/core@1.15.47(@swc/helpers@0.5.23))(electron-builder-squirrel-windows@26.15.7)(electron@43.3.0)(esbuild@0.28.1)(rxjs@7.8.2)(typescript@5.9.3) + version: 22.0.0(patch_hash=f4bbde1778360c4c462b255bec47a337c0465d59cdcab14ecb601161f3467002)(@nx/devkit@22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))))(@nx/workspace@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(@swc/core@1.15.47(@swc/helpers@0.5.23))(electron-builder-squirrel-windows@26.15.7)(electron@43.3.0)(esbuild@0.28.2)(rxjs@7.8.2)(typescript@5.9.3) prettier: specifier: ^3.9.6 version: 3.9.6 @@ -414,10 +414,10 @@ importers: version: 0.35.3(@types/node@20.19.9) tailwindcss: specifier: ^3.4.19 - version: 3.4.19(tsx@4.23.11)(yaml@2.9.0) + version: 3.4.19(tsx@4.23.12)(yaml@2.9.0) ts-jest: specifier: ^29.4.12 - version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.2)(jest-util@30.4.1)(jest@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: 10.9.2 version: 10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3) @@ -425,14 +425,14 @@ importers: specifier: ^2.8.1 version: 2.8.1 tsx: - specifier: 4.23.11 - version: 4.23.11 + specifier: 4.23.12 + version: 4.23.12 typescript: specifier: 5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.66.0 - version: 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + specifier: ^8.67.0 + version: 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) yaml: specifier: 2.9.0 version: 2.9.0 @@ -1630,6 +1630,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.18.20': resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} engines: {node: '>=12'} @@ -1654,6 +1660,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.18.20': resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} engines: {node: '>=12'} @@ -1678,6 +1690,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.18.20': resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} engines: {node: '>=12'} @@ -1702,6 +1720,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.18.20': resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} engines: {node: '>=12'} @@ -1726,6 +1750,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.18.20': resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} engines: {node: '>=12'} @@ -1750,6 +1780,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} engines: {node: '>=12'} @@ -1774,6 +1810,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} engines: {node: '>=12'} @@ -1798,6 +1840,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.18.20': resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} engines: {node: '>=12'} @@ -1822,6 +1870,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.18.20': resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} engines: {node: '>=12'} @@ -1846,6 +1900,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.18.20': resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} engines: {node: '>=12'} @@ -1870,6 +1930,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.18.20': resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} engines: {node: '>=12'} @@ -1894,6 +1960,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.18.20': resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} engines: {node: '>=12'} @@ -1918,6 +1990,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.18.20': resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} engines: {node: '>=12'} @@ -1942,6 +2020,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.18.20': resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} engines: {node: '>=12'} @@ -1966,6 +2050,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.18.20': resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} engines: {node: '>=12'} @@ -1990,6 +2080,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.18.20': resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} engines: {node: '>=12'} @@ -2014,6 +2110,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -2032,6 +2134,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} engines: {node: '>=12'} @@ -2056,6 +2164,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -2074,6 +2188,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} engines: {node: '>=12'} @@ -2098,6 +2218,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -2116,6 +2242,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.18.20': resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} engines: {node: '>=12'} @@ -2140,6 +2272,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.18.20': resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} engines: {node: '>=12'} @@ -2164,6 +2302,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.18.20': resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} engines: {node: '>=12'} @@ -2188,6 +2332,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.18.20': resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} engines: {node: '>=12'} @@ -2212,6 +2362,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -4744,16 +4900,16 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.66.0': - resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} + '@typescript-eslint/eslint-plugin@8.67.0': + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.66.0 + '@typescript-eslint/parser': ^8.67.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.66.0': - resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==} + '@typescript-eslint/parser@8.67.0': + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4765,16 +4921,32 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.66.0': resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.66.0': resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.66.0': resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4782,16 +4954,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.67.0': + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.66.0': resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.66.0': resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.66.0': resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4799,10 +4988,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.66.0': resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -5010,6 +5210,7 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -6530,6 +6731,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -7162,8 +7368,8 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true - hls.js@1.6.17: - resolution: {integrity: sha512-NUplVGVuc1hSPwdB/9/cbRkUmLrYi75/hqiXKdA+l300pJNxDu96R7jRb2imDzWJqIUF4I5ThmAdp9GvOCXsuQ==} + hls.js@1.7.0: + resolution: {integrity: sha512-NnQpT6Z//+2IB2qovSUbKbrlXY1MOpi0JynZ9L2NNOuTJiNCyMSQj9k2laEj1v7CxU/Hb8oTWR98eT3mVpPQVQ==} hono@4.12.14: resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} @@ -8583,8 +8789,8 @@ packages: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} - ng-mocks@14.16.1: - resolution: {integrity: sha512-vag8gK5PFO8edUAKnPtIoCWurqSsI5uKOf1+V/KsffQdVhAstoqpmzjRAW4hVaVUN581Sfy5oectNFNmi38xXg==} + ng-mocks@14.17.1: + resolution: {integrity: sha512-YxHOVaXoCTBlR89vXs/niq8v7ncyIgAP5MUuFG6F80LHvC7wRw+chto2hiboKboJJ6V9uynIhZzLsSIVyti9gA==} peerDependencies: '@angular/common': 5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19 || 20.0.0-alpha - 20 || 21.0.0-alpha - 21 || 22.0.0-alpha - 22 '@angular/core': 5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19 || 20.0.0-alpha - 20 || 21.0.0-alpha - 21 || 22.0.0-alpha - 22 @@ -10681,8 +10887,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.11: - resolution: {integrity: sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} engines: {node: '>=18.0.0'} hasBin: true @@ -10745,8 +10951,8 @@ packages: typed-assert@1.0.9: resolution: {integrity: sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==} - typescript-eslint@8.66.0: - resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} + typescript-eslint@8.67.0: + resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -11675,54 +11881,54 @@ snapshots: '@angular-eslint/bundled-angular-compiler@21.4.0': {} - '@angular-eslint/eslint-plugin-template@21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/eslint-plugin-template@21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.67.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 21.3.1 '@angular-eslint/template-parser': 21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular-eslint/utils': 21.3.1(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/utils': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/utils': 21.3.1(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) aria-query: 5.3.2 axobject-query: 4.1.0 eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 - '@angular-eslint/eslint-plugin-template@21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/eslint-plugin-template@21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 21.4.0 '@angular-eslint/template-parser': 21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular-eslint/utils': 21.4.0(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/utils': 21.4.0(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/utils': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) aria-query: 5.3.2 axobject-query: 4.1.0 eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 - '@angular-eslint/eslint-plugin@21.3.1(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/eslint-plugin@21.3.1(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 21.3.1 - '@angular-eslint/utils': 21.3.1(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/utils': 21.3.1(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 - '@angular-eslint/eslint-plugin@21.4.0(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/eslint-plugin@21.4.0(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 21.4.0 - '@angular-eslint/utils': 21.4.0(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/utils': 21.4.0(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 - '@angular-eslint/schematics@21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/schematics@21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.67.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-devkit/core': 21.2.19(chokidar@5.0.0) '@angular-devkit/schematics': 21.2.19(chokidar@5.0.0) - '@angular-eslint/eslint-plugin': 21.3.1(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular-eslint/eslint-plugin-template': 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/eslint-plugin': 21.3.1(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/eslint-plugin-template': 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.67.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular/cli': 21.2.19(@types/node@20.19.9)(chokidar@5.0.0) ignore: 7.0.5 semver: 7.7.4 @@ -11735,12 +11941,12 @@ snapshots: - eslint - typescript - '@angular-eslint/schematics@21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/schematics@21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-devkit/core': 21.2.19(chokidar@5.0.0) '@angular-devkit/schematics': 21.2.19(chokidar@5.0.0) - '@angular-eslint/eslint-plugin': 21.4.0(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular-eslint/eslint-plugin-template': 21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/eslint-plugin': 21.4.0(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/eslint-plugin-template': 21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular/cli': 21.2.19(@types/node@20.19.9)(chokidar@5.0.0) ignore: 7.0.5 semver: 7.7.4 @@ -11767,17 +11973,17 @@ snapshots: eslint-scope: 9.1.2 typescript: 5.9.3 - '@angular-eslint/utils@21.3.1(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/utils@21.3.1(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 21.3.1 - '@typescript-eslint/utils': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 - '@angular-eslint/utils@21.4.0(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/utils@21.4.0(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 21.4.0 - '@typescript-eslint/utils': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 @@ -11786,7 +11992,7 @@ snapshots: '@angular/core': 21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2) tslib: 2.8.1 - '@angular/build@21.2.19(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(typescript@5.9.3))(@angular/compiler@21.2.19)(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.19(@angular/animations@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/service-worker@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(chokidar@5.0.0)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.100.0)(tailwindcss@3.4.19(tsx@4.23.11)(yaml@2.9.0))(terser@5.49.2)(tslib@2.8.1)(tsx@4.23.11)(typescript@5.9.3)(yaml@2.9.0)': + '@angular/build@21.2.19(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(typescript@5.9.3))(@angular/compiler@21.2.19)(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.19(@angular/animations@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/service-worker@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(chokidar@5.0.0)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.100.0)(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0))(terser@5.49.2)(tslib@2.8.1)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2102.19(chokidar@5.0.0) @@ -11796,7 +12002,7 @@ snapshots: '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-split-export-declaration': 7.24.7 '@inquirer/confirm': 5.1.21(@types/node@20.19.9) - '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.6(patch_hash=215b6585a9c92fa68c096d500f4676983200307b1e64c1321e20ccbd50bbc8f3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.97.3)(terser@5.49.2)(tsx@4.23.11)(yaml@2.9.0)) + '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.6(patch_hash=215b6585a9c92fa68c096d500f4676983200307b1e64c1321e20ccbd50bbc8f3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.97.3)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0)) beasties: 0.4.1 browserslist: 4.28.7 esbuild: 0.28.1 @@ -11817,7 +12023,7 @@ snapshots: tslib: 2.8.1 typescript: 5.9.3 undici: 7.28.0 - vite: 7.3.6(patch_hash=215b6585a9c92fa68c096d500f4676983200307b1e64c1321e20ccbd50bbc8f3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.97.3)(terser@5.49.2)(tsx@4.23.11)(yaml@2.9.0) + vite: 7.3.6(patch_hash=215b6585a9c92fa68c096d500f4676983200307b1e64c1321e20ccbd50bbc8f3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.97.3)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0) watchpack: 2.5.1 optionalDependencies: '@angular/core': 21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2) @@ -11826,7 +12032,7 @@ snapshots: less: 4.5.1 lmdb: 3.5.1 postcss: 8.5.6 - tailwindcss: 3.4.19(tsx@4.23.11)(yaml@2.9.0) + tailwindcss: 3.4.19(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -12028,12 +12234,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@4.3.13(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.11)(typescript@5.9.3)(yaml@2.9.0))': + '@astrojs/mdx@4.3.13(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0))': dependencies: '@astrojs/markdown-remark': 6.3.10 '@mdx-js/mdx': 3.1.1 acorn: 8.15.0 - astro: 5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.11)(typescript@5.9.3)(yaml@2.9.0) + astro: 5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) es-module-lexer: 1.7.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -12057,13 +12263,13 @@ snapshots: stream-replace-string: 2.0.0 zod: 4.3.6 - '@astrojs/tailwind@6.0.2(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.11)(typescript@5.9.3)(yaml@2.9.0))(tailwindcss@3.4.19(tsx@4.23.11)(yaml@2.9.0))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))': + '@astrojs/tailwind@6.0.2(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0))(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))': dependencies: - astro: 5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.11)(typescript@5.9.3)(yaml@2.9.0) + astro: 5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) autoprefixer: 10.4.23(postcss@8.5.6) postcss: 8.5.6 postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) - tailwindcss: 3.4.19(tsx@4.23.11)(yaml@2.9.0) + tailwindcss: 3.4.19(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - ts-node @@ -13091,6 +13297,9 @@ snapshots: '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.18.20': optional: true @@ -13103,6 +13312,9 @@ snapshots: '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.18.20': optional: true @@ -13115,6 +13327,9 @@ snapshots: '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.18.20': optional: true @@ -13127,6 +13342,9 @@ snapshots: '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.18.20': optional: true @@ -13139,6 +13357,9 @@ snapshots: '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.18.20': optional: true @@ -13151,6 +13372,9 @@ snapshots: '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.18.20': optional: true @@ -13163,6 +13387,9 @@ snapshots: '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.18.20': optional: true @@ -13175,6 +13402,9 @@ snapshots: '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.18.20': optional: true @@ -13187,6 +13417,9 @@ snapshots: '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.18.20': optional: true @@ -13199,6 +13432,9 @@ snapshots: '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.18.20': optional: true @@ -13211,6 +13447,9 @@ snapshots: '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.18.20': optional: true @@ -13223,6 +13462,9 @@ snapshots: '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.18.20': optional: true @@ -13235,6 +13477,9 @@ snapshots: '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.18.20': optional: true @@ -13247,6 +13492,9 @@ snapshots: '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.18.20': optional: true @@ -13259,6 +13507,9 @@ snapshots: '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.18.20': optional: true @@ -13271,6 +13522,9 @@ snapshots: '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.18.20': optional: true @@ -13283,6 +13537,9 @@ snapshots: '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true @@ -13292,6 +13549,9 @@ snapshots: '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.18.20': optional: true @@ -13304,6 +13564,9 @@ snapshots: '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true @@ -13313,6 +13576,9 @@ snapshots: '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.18.20': optional: true @@ -13325,6 +13591,9 @@ snapshots: '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true @@ -13334,6 +13603,9 @@ snapshots: '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.18.20': optional: true @@ -13346,6 +13618,9 @@ snapshots: '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.18.20': optional: true @@ -13358,6 +13633,9 @@ snapshots: '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.18.20': optional: true @@ -13370,6 +13648,9 @@ snapshots: '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.18.20': optional: true @@ -13382,6 +13663,9 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.2(jiti@1.21.7))': dependencies: eslint: 9.39.2(jiti@1.21.7) @@ -13823,7 +14107,7 @@ snapshots: jest-util: 30.4.1 slash: 3.0.0 - '@jest/core@30.4.2(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))': + '@jest/core@30.4.2(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))': dependencies: '@jest/console': 30.4.1 '@jest/pattern': 30.4.0 @@ -13839,7 +14123,7 @@ snapshots: fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 jest-changed-files: 30.4.1 - jest-config: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + jest-config: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) jest-haste-map: 30.4.1 jest-message-util: 30.4.1 jest-regex-util: 30.4.0 @@ -14339,7 +14623,7 @@ snapshots: - bufferutil - utf-8-validate - '@module-federation/enhanced@2.8.2(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6))': + '@module-federation/enhanced@2.8.2(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6))': dependencies: '@module-federation/bridge-react-webpack-plugin': 2.8.2 '@module-federation/cli': 2.8.2(typescript@5.9.3) @@ -14356,7 +14640,7 @@ snapshots: tapable: 2.3.0 optionalDependencies: typescript: 5.9.3 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) transitivePeerDependencies: - '@rspack/core' - bufferutil @@ -14385,16 +14669,16 @@ snapshots: - utf-8-validate - vue-tsc - '@module-federation/node@2.7.49(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6))': + '@module-federation/node@2.7.49(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6))': dependencies: - '@module-federation/enhanced': 2.8.2(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + '@module-federation/enhanced': 2.8.2(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) '@module-federation/runtime': 2.8.2 '@module-federation/sdk': 2.8.2 encoding: 0.1.13 node-fetch: 2.7.0(encoding@0.1.13) tapable: 2.3.0 optionalDependencies: - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) transitivePeerDependencies: - '@rspack/core' - bufferutil @@ -14605,13 +14889,13 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 - '@ngrx/eslint-plugin@21.1.1(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3)': + '@ngrx/eslint-plugin@21.1.1(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) strip-json-comments: 3.1.1 typescript: 5.9.3 - typescript-eslint: 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + typescript-eslint: 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@ngrx/router-store@21.1.1(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/router@21.2.19(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.19(@angular/animations@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2))(@ngrx/store@21.1.1(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: @@ -14750,7 +15034,7 @@ snapshots: '@npmcli/fs@5.0.0': dependencies: - semver: 7.8.5 + semver: 7.7.4 '@npmcli/git@7.0.2': dependencies: @@ -14760,7 +15044,7 @@ snapshots: lru-cache: 11.5.2 npm-pick-manifest: 11.0.3 proc-log: 6.1.0 - semver: 7.8.5 + semver: 7.7.4 which: 6.0.1 '@npmcli/installed-package-contents@4.0.0': @@ -14777,7 +15061,7 @@ snapshots: hosted-git-info: 9.0.3 json-parse-even-better-errors: 5.0.0 proc-log: 6.1.0 - semver: 7.8.5 + semver: 7.7.4 spdx-expression-parse: 4.0.0 '@npmcli/promise-spawn@9.0.1': @@ -14794,17 +15078,17 @@ snapshots: node-gyp: 12.4.0 proc-log: 6.1.0 - '@nx/angular@22.7.8(e8795a5656eb779e74dd501224407fda)': + '@nx/angular@22.7.8(d05e5b074e75040418244af5b15d2172)': dependencies: '@angular-devkit/core': 21.2.19(chokidar@5.0.0) '@angular-devkit/schematics': 21.2.19(chokidar@5.0.0) '@nx/devkit': 22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) - '@nx/eslint': 22.7.8(19987e244c6e3d0a20d130ecb4380e79) + '@nx/eslint': 22.7.8(5d8e116ecf28960b728f05bee433010d) '@nx/js': 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) - '@nx/module-federation': 22.7.8(07b6003a6ef8c1dd8e17a5c1fe0dcb63) - '@nx/rspack': 22.7.8(e18a968ae9ff80b2c678db3b0b03e3cc) - '@nx/web': 22.7.8(6972877962196def71b9e579ef557689) - '@nx/webpack': 22.7.8(@babel/traverse@7.29.8)(@rspack/core@1.6.8(@swc/helpers@0.5.23))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(typescript@5.9.3) + '@nx/module-federation': 22.7.8(e48260553d735d3ac9a14e5e3a100793) + '@nx/rspack': 22.7.8(1841ce88f783605646f618ba436ce52d) + '@nx/web': 22.7.8(13c5604b3d97b74c688c777b807087c9) + '@nx/webpack': 22.7.8(@babel/traverse@7.29.8)(@rspack/core@1.6.8(@swc/helpers@0.5.23))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(typescript@5.9.3) '@nx/workspace': 22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)) '@phenomnomnominal/tsquery': 6.2.0(typescript@5.9.3) '@schematics/angular': 21.2.19(chokidar@5.0.0) @@ -14818,7 +15102,7 @@ snapshots: tslib: 2.8.1 webpack-merge: 5.10.0 optionalDependencies: - '@angular/build': 21.2.19(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(typescript@5.9.3))(@angular/compiler@21.2.19)(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.19(@angular/animations@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/service-worker@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(chokidar@5.0.0)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.100.0)(tailwindcss@3.4.19(tsx@4.23.11)(yaml@2.9.0))(terser@5.49.2)(tslib@2.8.1)(tsx@4.23.11)(typescript@5.9.3)(yaml@2.9.0) + '@angular/build': 21.2.19(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(typescript@5.9.3))(@angular/compiler@21.2.19)(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.19(@angular/animations@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/service-worker@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(chokidar@5.0.0)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.100.0)(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0))(terser@5.49.2)(tslib@2.8.1)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) transitivePeerDependencies: - '@babel/traverse' - '@minify-html/node' @@ -14872,7 +15156,7 @@ snapshots: tslib: 2.8.1 yargs-parser: 21.1.1 - '@nx/esbuild@22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))': + '@nx/esbuild@22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))': dependencies: '@nx/devkit': 22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) '@nx/js': 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) @@ -14881,7 +15165,7 @@ snapshots: tsconfig-paths: 4.2.0 tslib: 2.8.1 optionalDependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -14890,14 +15174,14 @@ snapshots: - supports-color - verdaccio - '@nx/eslint-plugin@22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript-eslint/parser@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(typescript@5.9.3)': + '@nx/eslint-plugin@22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(typescript@5.9.3)': dependencies: '@nx/devkit': 22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) '@nx/js': 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) '@phenomnomnominal/tsquery': 6.2.0(typescript@5.9.3) - '@typescript-eslint/parser': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/type-utils': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) chalk: 4.1.2 confusing-browser-globals: 1.0.11 globals: 17.9.0 @@ -14916,7 +15200,7 @@ snapshots: - typescript - verdaccio - '@nx/eslint@22.7.8(19987e244c6e3d0a20d130ecb4380e79)': + '@nx/eslint@22.7.8(5d8e116ecf28960b728f05bee433010d)': dependencies: '@nx/devkit': 22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) '@nx/js': 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) @@ -14925,7 +15209,7 @@ snapshots: tslib: 2.8.1 typescript: 5.9.3 optionalDependencies: - '@nx/jest': 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3) + '@nx/jest': 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3) '@zkochan/js-yaml': 0.0.7 transitivePeerDependencies: - '@babel/traverse' @@ -14935,7 +15219,7 @@ snapshots: - supports-color - verdaccio - '@nx/jest@22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3)': + '@nx/jest@22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@jest/reporters': 30.4.1 '@jest/test-result': 30.4.1 @@ -14943,7 +15227,7 @@ snapshots: '@nx/js': 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) '@phenomnomnominal/tsquery': 6.2.0(typescript@5.9.3) identity-obj-proxy: 3.0.0 - jest-config: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + jest-config: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) jest-resolve: 30.4.1 jest-util: 30.4.1 minimatch: 10.2.5 @@ -15001,20 +15285,20 @@ snapshots: - nx - supports-color - '@nx/module-federation@22.7.8(07b6003a6ef8c1dd8e17a5c1fe0dcb63)': + '@nx/module-federation@22.7.8(233c5ea2e5e8fcc68f4f23d731d3dab7)': dependencies: - '@module-federation/enhanced': 2.8.2(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) - '@module-federation/node': 2.7.49(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + '@module-federation/enhanced': 2.8.2(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) + '@module-federation/node': 2.7.49(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) '@module-federation/sdk': 2.8.2 '@nx/devkit': 22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) '@nx/js': 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) - '@nx/web': 22.7.8(6972877962196def71b9e579ef557689) + '@nx/web': 22.7.8(13c5604b3d97b74c688c777b807087c9) '@rspack/core': 1.6.8(@swc/helpers@0.5.23) express: 4.22.2 http-proxy-middleware: 3.0.7 picocolors: 1.1.1 tslib: 2.8.1 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.26) transitivePeerDependencies: - '@babel/traverse' - '@minify-html/node' @@ -15047,20 +15331,20 @@ snapshots: - vue-tsc - webpack-cli - '@nx/module-federation@22.7.8(7113e48f4bd5bda65a35a51fedeab35c)': + '@nx/module-federation@22.7.8(e48260553d735d3ac9a14e5e3a100793)': dependencies: - '@module-federation/enhanced': 2.8.2(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) - '@module-federation/node': 2.7.49(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + '@module-federation/enhanced': 2.8.2(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) + '@module-federation/node': 2.7.49(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) '@module-federation/sdk': 2.8.2 '@nx/devkit': 22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) '@nx/js': 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) - '@nx/web': 22.7.8(6972877962196def71b9e579ef557689) + '@nx/web': 22.7.8(13c5604b3d97b74c688c777b807087c9) '@rspack/core': 1.6.8(@swc/helpers@0.5.23) express: 4.22.2 http-proxy-middleware: 3.0.7 picocolors: 1.1.1 tslib: 2.8.1 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.26) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) transitivePeerDependencies: - '@babel/traverse' - '@minify-html/node' @@ -15123,10 +15407,10 @@ snapshots: '@nx/nx-win32-x64-msvc@22.7.8': optional: true - '@nx/playwright@22.7.8(38b9a7d3b8a3cfecba597fb22d827e8b)': + '@nx/playwright@22.7.8(fd8f618a9324a70c524ebeba2216a705)': dependencies: '@nx/devkit': 22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) - '@nx/eslint': 22.7.8(19987e244c6e3d0a20d130ecb4380e79) + '@nx/eslint': 22.7.8(5d8e116ecf28960b728f05bee433010d) '@nx/js': 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) minimatch: 10.2.5 tslib: 2.8.1 @@ -15143,40 +15427,40 @@ snapshots: - supports-color - verdaccio - '@nx/rspack@22.7.8(e18a968ae9ff80b2c678db3b0b03e3cc)': + '@nx/rspack@22.7.8(1841ce88f783605646f618ba436ce52d)': dependencies: - '@module-federation/enhanced': 2.8.2(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) - '@module-federation/node': 2.7.49(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + '@module-federation/enhanced': 2.8.2(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) + '@module-federation/node': 2.7.49(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) '@nx/devkit': 22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) '@nx/js': 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) - '@nx/module-federation': 22.7.8(7113e48f4bd5bda65a35a51fedeab35c) - '@nx/web': 22.7.8(6972877962196def71b9e579ef557689) + '@nx/module-federation': 22.7.8(233c5ea2e5e8fcc68f4f23d731d3dab7) + '@nx/web': 22.7.8(13c5604b3d97b74c688c777b807087c9) '@phenomnomnominal/tsquery': 6.2.0(typescript@5.9.3) '@rspack/core': 1.6.8(@swc/helpers@0.5.23) - '@rspack/dev-server': 1.2.1(@rspack/core@1.6.8(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + '@rspack/dev-server': 1.2.1(@rspack/core@1.6.8(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) '@rspack/plugin-react-refresh': 1.6.2(react-refresh@0.18.0) autoprefixer: 10.5.4(postcss@8.5.26) browserslist: 4.28.8 - css-loader: 6.11.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + css-loader: 6.11.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) enquirer: 2.3.6 express: 4.22.2 http-proxy-middleware: 3.0.7 - less-loader: 12.3.3(@rspack/core@1.6.8(@swc/helpers@0.5.23))(less@4.5.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) - license-webpack-plugin: 4.0.2(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + less-loader: 12.3.3(@rspack/core@1.6.8(@swc/helpers@0.5.23))(less@4.5.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) + license-webpack-plugin: 4.0.2(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) loader-utils: 2.0.4 parse5: 4.0.0 picocolors: 1.1.1 postcss: 8.5.26 postcss-import: 14.1.0(postcss@8.5.26) - postcss-loader: 8.2.1(@rspack/core@1.6.8(@swc/helpers@0.5.23))(postcss@8.5.26)(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + postcss-loader: 8.2.1(@rspack/core@1.6.8(@swc/helpers@0.5.23))(postcss@8.5.26)(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) sass: 1.102.0 sass-embedded: 1.100.0 - sass-loader: 16.0.8(@rspack/core@1.6.8(@swc/helpers@0.5.23))(sass-embedded@1.100.0)(sass@1.102.0)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) - source-map-loader: 5.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) - style-loader: 3.3.4(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + sass-loader: 16.0.8(@rspack/core@1.6.8(@swc/helpers@0.5.23))(sass-embedded@1.100.0)(sass@1.102.0)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) + source-map-loader: 5.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) + style-loader: 3.3.4(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) ts-checker-rspack-plugin: 1.6.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3) tslib: 2.8.1 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.26) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.26) webpack-node-externals: 3.0.0 transitivePeerDependencies: - '@babel/traverse' @@ -15214,7 +15498,7 @@ snapshots: - webpack-cli - webpack-hot-middleware - '@nx/web@22.7.8(6972877962196def71b9e579ef557689)': + '@nx/web@22.7.8(13c5604b3d97b74c688c777b807087c9)': dependencies: '@nx/devkit': 22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) '@nx/js': 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) @@ -15223,10 +15507,10 @@ snapshots: picocolors: 1.1.1 tslib: 2.8.1 optionalDependencies: - '@nx/eslint': 22.7.8(19987e244c6e3d0a20d130ecb4380e79) - '@nx/jest': 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3) - '@nx/playwright': 22.7.8(38b9a7d3b8a3cfecba597fb22d827e8b) - '@nx/webpack': 22.7.8(@babel/traverse@7.29.8)(@rspack/core@1.6.8(@swc/helpers@0.5.23))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(typescript@5.9.3) + '@nx/eslint': 22.7.8(5d8e116ecf28960b728f05bee433010d) + '@nx/jest': 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3) + '@nx/playwright': 22.7.8(fd8f618a9324a70c524ebeba2216a705) + '@nx/webpack': 22.7.8(@babel/traverse@7.29.8)(@rspack/core@1.6.8(@swc/helpers@0.5.23))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(typescript@5.9.3) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -15236,7 +15520,7 @@ snapshots: - supports-color - verdaccio - '@nx/webpack@22.7.8(@babel/traverse@7.29.8)(@rspack/core@1.6.8(@swc/helpers@0.5.23))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(typescript@5.9.3)': + '@nx/webpack@22.7.8(@babel/traverse@7.29.8)(@rspack/core@1.6.8(@swc/helpers@0.5.23))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(typescript@5.9.3)': dependencies: '@babel/core': 7.29.7 '@nx/devkit': 22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) @@ -15244,36 +15528,36 @@ snapshots: '@phenomnomnominal/tsquery': 6.2.0(typescript@5.9.3) ajv: 8.18.0 autoprefixer: 10.5.4(postcss@8.5.26) - babel-loader: 9.2.1(@babel/core@7.29.7)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + babel-loader: 9.2.1(@babel/core@7.29.7)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) browserslist: 4.28.8 - copy-webpack-plugin: 14.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) - css-loader: 6.11.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) - css-minimizer-webpack-plugin: 8.0.0(esbuild@0.28.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + copy-webpack-plugin: 14.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) + css-loader: 6.11.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) + css-minimizer-webpack-plugin: 8.0.0(esbuild@0.28.2)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) less: 4.5.1 - less-loader: 12.3.3(@rspack/core@1.6.8(@swc/helpers@0.5.23))(less@4.5.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) - license-webpack-plugin: 4.0.2(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + less-loader: 12.3.3(@rspack/core@1.6.8(@swc/helpers@0.5.23))(less@4.5.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) + license-webpack-plugin: 4.0.2(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) loader-utils: 2.0.4 - mini-css-extract-plugin: 2.4.7(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + mini-css-extract-plugin: 2.4.7(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) parse5: 4.0.0 picocolors: 1.1.1 postcss: 8.5.26 postcss-import: 14.1.0(postcss@8.5.26) - postcss-loader: 8.2.1(@rspack/core@1.6.8(@swc/helpers@0.5.23))(postcss@8.5.26)(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + postcss-loader: 8.2.1(@rspack/core@1.6.8(@swc/helpers@0.5.23))(postcss@8.5.26)(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) rxjs: 7.8.2 sass: 1.102.0 sass-embedded: 1.100.0 - sass-loader: 16.0.8(@rspack/core@1.6.8(@swc/helpers@0.5.23))(sass-embedded@1.100.0)(sass@1.102.0)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) - source-map-loader: 5.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) - style-loader: 3.3.4(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) - terser-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.26)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) - ts-loader: 9.6.2(loader-utils@2.0.4)(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + sass-loader: 16.0.8(@rspack/core@1.6.8(@swc/helpers@0.5.23))(sass-embedded@1.100.0)(sass@1.102.0)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) + source-map-loader: 5.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) + style-loader: 3.3.4(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) + terser-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.26)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) + ts-loader: 9.6.2(loader-utils@2.0.4)(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) tsconfig-paths-webpack-plugin: 4.2.0 tslib: 2.8.1 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.26) - webpack-dev-server: 5.2.6(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.26) + webpack-dev-server: 5.2.6(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) webpack-node-externals: 3.0.0 - webpack-subresource-integrity: 5.1.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + webpack-subresource-integrity: 5.1.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) transitivePeerDependencies: - '@babel/traverse' - '@minify-html/node' @@ -15745,7 +16029,7 @@ snapshots: optionalDependencies: '@swc/helpers': 0.5.23 - '@rspack/dev-server@1.2.1(@rspack/core@1.6.8(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6))': + '@rspack/dev-server@1.2.1(@rspack/core@1.6.8(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6))': dependencies: '@rspack/core': 1.6.8(@swc/helpers@0.5.23) '@types/bonjour': 3.5.13 @@ -15774,7 +16058,7 @@ snapshots: serve-index: 1.9.2 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.5(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + webpack-dev-middleware: 7.4.5(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) ws: 8.21.3 transitivePeerDependencies: - bufferutil @@ -15985,10 +16269,10 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tailwindcss/typography@0.5.20(tailwindcss@3.4.19(tsx@4.23.11)(yaml@2.9.0))': + '@tailwindcss/typography@0.5.20(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0))': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.19(tsx@4.23.11)(yaml@2.9.0) + tailwindcss: 3.4.19(tsx@4.23.12)(yaml@2.9.0) '@tsconfig/node10@1.0.12': {} @@ -16262,14 +16546,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.66.0 - '@typescript-eslint/type-utils': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.66.0 + '@typescript-eslint/parser': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/type-utils': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.67.0 eslint: 9.39.2(jiti@1.21.7) ignore: 7.0.6 natural-compare: 1.4.0 @@ -16278,12 +16562,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.66.0 - '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.66.0 + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.67.0 debug: 4.4.3(supports-color@7.2.0) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 @@ -16299,15 +16583,33 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + debug: 4.4.3(supports-color@7.2.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.66.0': dependencies: '@typescript-eslint/types': 8.66.0 '@typescript-eslint/visitor-keys': 8.66.0 + '@typescript-eslint/scope-manager@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + '@typescript-eslint/tsconfig-utils@8.66.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + '@typescript-eslint/type-utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.66.0 @@ -16320,8 +16622,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.39.2(jiti@1.21.7) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.66.0': {} + '@typescript-eslint/types@8.67.0': {} + '@typescript-eslint/typescript-estree@8.66.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.66.0(typescript@5.9.3) @@ -16337,6 +16653,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3(supports-color@7.2.0) + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.2(jiti@1.21.7)) @@ -16348,11 +16679,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.2(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.66.0': dependencies: '@typescript-eslint/types': 8.66.0 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.0': {} '@ungap/structured-clone@1.3.3': {} @@ -16472,9 +16819,9 @@ snapshots: global: 4.4.0 is-function: 1.0.2 - '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.6(patch_hash=215b6585a9c92fa68c096d500f4676983200307b1e64c1321e20ccbd50bbc8f3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.97.3)(terser@5.49.2)(tsx@4.23.11)(yaml@2.9.0))': + '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.6(patch_hash=215b6585a9c92fa68c096d500f4676983200307b1e64c1321e20ccbd50bbc8f3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.97.3)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0))': dependencies: - vite: 7.3.6(patch_hash=215b6585a9c92fa68c096d500f4676983200307b1e64c1321e20ccbd50bbc8f3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.97.3)(terser@5.49.2)(tsx@4.23.11)(yaml@2.9.0) + vite: 7.3.6(patch_hash=215b6585a9c92fa68c096d500f4676983200307b1e64c1321e20ccbd50bbc8f3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.97.3)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0) '@webassemblyjs/ast@1.14.1': dependencies: @@ -16671,21 +17018,21 @@ snapshots: '@algolia/requester-fetch': 5.48.1 '@algolia/requester-node-http': 5.48.1 - angular-eslint@21.4.0(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3): + angular-eslint@21.4.0(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3): dependencies: '@angular-devkit/core': 21.2.19(chokidar@5.0.0) '@angular-devkit/schematics': 21.2.19(chokidar@5.0.0) '@angular-eslint/builder': 21.4.0(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular-eslint/eslint-plugin': 21.4.0(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular-eslint/eslint-plugin-template': 21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular-eslint/schematics': 21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/eslint-plugin': 21.4.0(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/eslint-plugin-template': 21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/schematics': 21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/template-parser': 21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular/cli': 21.2.19(@types/node@20.19.9)(chokidar@5.0.0) '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/utils': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 - typescript-eslint: 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + typescript-eslint: 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) transitivePeerDependencies: - chokidar - supports-color @@ -16858,7 +17205,7 @@ snapshots: astring@1.9.0: {} - astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.11)(typescript@5.9.3)(yaml@2.9.0): + astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0): dependencies: '@astrojs/compiler': 2.13.1 '@astrojs/internal-helpers': 0.7.6 @@ -16915,8 +17262,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.4 vfile: 6.0.3 - vite: 6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.11)(yaml@2.9.0) - vitefu: 1.1.1(vite@6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.11)(yaml@2.9.0)) + vite: 6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0) + vitefu: 1.1.1(vite@6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 yocto-spinner: 0.2.3 @@ -17029,12 +17376,12 @@ snapshots: transitivePeerDependencies: - supports-color - babel-loader@9.2.1(@babel/core@7.29.7)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + babel-loader@9.2.1(@babel/core@7.29.7)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: '@babel/core': 7.29.7 find-cache-dir: 4.0.0 schema-utils: 4.3.3 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) babel-plugin-const-enum@1.2.0(@babel/core@7.29.7): dependencies: @@ -17588,7 +17935,7 @@ snapshots: dependencies: is-what: 3.14.1 - copy-webpack-plugin@12.0.2(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)): + copy-webpack-plugin@12.0.2(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)): dependencies: fast-glob: 3.3.3 glob-parent: 6.0.2 @@ -17596,16 +17943,16 @@ snapshots: normalize-path: 3.0.0 schema-utils: 4.3.3 serialize-javascript: 6.0.2 - webpack: 5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2) - copy-webpack-plugin@14.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + copy-webpack-plugin@14.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: glob-parent: 6.0.2 normalize-path: 3.0.0 schema-utils: 4.3.3 serialize-javascript: 7.1.0 tinyglobby: 0.2.17 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) core-js-compat@3.50.0: dependencies: @@ -17665,7 +18012,7 @@ snapshots: dependencies: postcss: 8.5.26 - css-loader@6.11.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + css-loader@6.11.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: icss-utils: 5.1.0(postcss@8.5.26) postcss: 8.5.26 @@ -17677,9 +18024,9 @@ snapshots: semver: 7.8.5 optionalDependencies: '@rspack/core': 1.6.8(@swc/helpers@0.5.23) - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) - css-minimizer-webpack-plugin@8.0.0(esbuild@0.28.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + css-minimizer-webpack-plugin@8.0.0(esbuild@0.28.2)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: '@jridgewell/trace-mapping': 0.3.31 cssnano: 7.1.9(postcss@8.5.26) @@ -17687,9 +18034,9 @@ snapshots: postcss: 8.5.26 schema-utils: 4.3.3 serialize-javascript: 7.1.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) optionalDependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 css-select@5.2.2: dependencies: @@ -17977,7 +18324,7 @@ snapshots: '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 esbuild: 0.25.12 - tsx: 4.23.11 + tsx: 4.23.12 drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@13.0.3): optionalDependencies: @@ -18273,10 +18620,10 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 - esbuild-register@3.6.0(esbuild@0.28.1): + esbuild-register@3.6.0(esbuild@0.28.2): dependencies: debug: 4.4.3(supports-color@7.2.0) - esbuild: 0.28.1 + esbuild: 0.28.2 transitivePeerDependencies: - supports-color optional: true @@ -18395,6 +18742,35 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -18419,17 +18795,17 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -18440,7 +18816,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -18452,7 +18828,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -18856,7 +19232,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)): + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)): dependencies: '@babel/code-frame': 7.27.1 chalk: 4.1.2 @@ -18871,9 +19247,9 @@ snapshots: semver: 7.8.5 tapable: 2.3.0 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2) - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: '@babel/code-frame': 7.27.1 chalk: 4.1.2 @@ -18888,7 +19264,7 @@ snapshots: semver: 7.8.5 tapable: 2.3.0 typescript: 5.9.3 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) form-data@4.0.6: dependencies: @@ -19064,7 +19440,7 @@ snapshots: es6-error: 4.1.1 matcher: 3.0.0 roarr: 2.15.4 - semver: 7.8.5 + semver: 7.7.4 serialize-error: 7.0.1 optional: true @@ -19296,7 +19672,7 @@ snapshots: he@1.2.0: {} - hls.js@1.6.17: {} + hls.js@1.7.0: {} hono@4.12.14: {} @@ -19804,15 +20180,15 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)): + jest-cli@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)): dependencies: - '@jest/core': 30.4.2(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + '@jest/core': 30.4.2(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) '@jest/test-result': 30.4.1 '@jest/types': 30.4.1 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + jest-config: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) jest-util: 30.4.1 jest-validate: 30.4.1 yargs: 17.7.3 @@ -19823,7 +20199,7 @@ snapshots: - supports-color - ts-node - jest-config@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)): + jest-config@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.7 '@jest/get-type': 30.1.0 @@ -19850,7 +20226,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.19.9 - esbuild-register: 3.6.0(esbuild@0.28.1) + esbuild-register: 3.6.0(esbuild@0.28.2) ts-node: 10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros @@ -19977,7 +20353,7 @@ snapshots: optionalDependencies: jest-resolve: 30.4.1 - jest-preset-angular@15.0.3(36d1d9397856c52c68162d7a1ca96a4c): + jest-preset-angular@15.0.3(e3b998715f9ba0e14eb4dd646f182898): dependencies: '@angular/compiler-cli': 21.2.19(@angular/compiler@21.2.19)(typescript@5.9.3) '@angular/core': 21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2) @@ -19986,14 +20362,14 @@ snapshots: '@jest/environment-jsdom-abstract': 30.2.0(jsdom@26.1.0) bs-logger: 0.2.6 esbuild-wasm: 0.27.2 - jest: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + jest: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) jest-util: 30.4.1 jsdom: 26.1.0 pretty-format: 30.2.0 - ts-jest: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3) + ts-jest: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.2)(jest-util@30.4.1)(jest@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3) typescript: 5.9.3 optionalDependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 transitivePeerDependencies: - '@babel/core' - '@jest/transform' @@ -20155,12 +20531,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)): + jest@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)): dependencies: - '@jest/core': 30.4.2(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + '@jest/core': 30.4.2(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) '@jest/types': 30.4.1 import-local: 3.2.0 - jest-cli: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + jest-cli: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -20279,12 +20655,12 @@ snapshots: lazy-val@1.0.5: {} - less-loader@12.3.3(@rspack/core@1.6.8(@swc/helpers@0.5.23))(less@4.5.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + less-loader@12.3.3(@rspack/core@1.6.8(@swc/helpers@0.5.23))(less@4.5.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: less: 4.5.1 optionalDependencies: '@rspack/core': 1.6.8(@swc/helpers@0.5.23) - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) less@4.5.1: dependencies: @@ -20307,17 +20683,17 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - license-webpack-plugin@4.0.2(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)): + license-webpack-plugin@4.0.2(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)): dependencies: webpack-sources: 3.3.3 optionalDependencies: - webpack: 5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2) - license-webpack-plugin@4.0.2(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + license-webpack-plugin@4.0.2(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: webpack-sources: 3.3.3 optionalDependencies: - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) lilconfig@3.1.3: {} @@ -21001,10 +21377,10 @@ snapshots: dependencies: dom-walk: 0.1.2 - mini-css-extract-plugin@2.4.7(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + mini-css-extract-plugin@2.4.7(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: schema-utils: 4.3.3 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) minimalistic-assert@1.0.1: {} @@ -21034,28 +21410,28 @@ snapshots: minimist@1.2.8: {} - minimizer-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.26)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + minimizer-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.26)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.49.2 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) optionalDependencies: '@swc/core': 1.15.47(@swc/helpers@0.5.23) - esbuild: 0.28.1 + esbuild: 0.28.2 postcss: 8.5.26 - minimizer-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + minimizer-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.49.2 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) optionalDependencies: '@swc/core': 1.15.47(@swc/helpers@0.5.23) - esbuild: 0.28.1 + esbuild: 0.28.2 postcss: 8.5.6 minipass-collect@2.0.1: @@ -21185,7 +21561,7 @@ snapshots: neotraverse@0.6.18: {} - ng-mocks@14.16.1(b285834fe0000b6ade3305b52068a4d2): + ng-mocks@14.17.1(b285834fe0000b6ade3305b52068a4d2): dependencies: '@angular/common': 21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) '@angular/core': 21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2) @@ -21210,7 +21586,7 @@ snapshots: node-abi@4.33.0: dependencies: - semver: 7.8.5 + semver: 7.7.4 node-abort-controller@3.1.1: {} @@ -21224,7 +21600,7 @@ snapshots: node-api-version@0.2.1: dependencies: - semver: 7.8.5 + semver: 7.7.4 node-fetch-native@1.6.7: {} @@ -21248,7 +21624,7 @@ snapshots: graceful-fs: 4.2.11 nopt: 9.0.0 proc-log: 6.1.0 - semver: 7.8.5 + semver: 7.7.4 tar: 7.5.22 tinyglobby: 0.2.17 undici: 6.28.0 @@ -21276,7 +21652,7 @@ snapshots: npm-install-checks@8.0.0: dependencies: - semver: 7.8.5 + semver: 7.7.4 npm-normalize-package-bin@5.0.0: {} @@ -21297,7 +21673,7 @@ snapshots: npm-install-checks: 8.0.0 npm-normalize-package-bin: 5.0.0 npm-package-arg: 13.0.2 - semver: 7.8.5 + semver: 7.7.4 npm-registry-fetch@19.1.1: dependencies: @@ -21322,22 +21698,22 @@ snapshots: nwsapi@2.2.23: {} - nx-electron@22.0.0(patch_hash=f4bbde1778360c4c462b255bec47a337c0465d59cdcab14ecb601161f3467002)(@nx/devkit@22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))))(@nx/workspace@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(@swc/core@1.15.47(@swc/helpers@0.5.23))(electron-builder-squirrel-windows@26.15.7)(electron@43.3.0)(esbuild@0.28.1)(rxjs@7.8.2)(typescript@5.9.3): + nx-electron@22.0.0(patch_hash=f4bbde1778360c4c462b255bec47a337c0465d59cdcab14ecb601161f3467002)(@nx/devkit@22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))))(@nx/workspace@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)))(@swc/core@1.15.47(@swc/helpers@0.5.23))(electron-builder-squirrel-windows@26.15.7)(electron@43.3.0)(esbuild@0.28.2)(rxjs@7.8.2)(typescript@5.9.3): dependencies: '@nx/devkit': 22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) '@nx/workspace': 22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23)) - copy-webpack-plugin: 12.0.2(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)) + copy-webpack-plugin: 12.0.2(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)) electron: 43.3.0 electron-builder: 26.15.7(electron-builder-squirrel-windows@26.15.7) - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)) - license-webpack-plugin: 4.0.2(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)) + license-webpack-plugin: 4.0.2(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)) rxjs-for-await: 1.0.0(rxjs@7.8.2) strip-json-comments: 3.1.1 - terser-webpack-plugin: 5.3.16(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)) + terser-webpack-plugin: 5.3.16(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)) tree-kill: 1.2.2 - ts-loader: 9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)) + ts-loader: 9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)) tsconfig-paths-webpack-plugin: 4.2.0 - webpack: 5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2) webpack-merge: 5.10.0 transitivePeerDependencies: - '@swc/core' @@ -21933,16 +22309,16 @@ snapshots: postcss: 8.5.6 ts-node: 10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3) - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.23.11)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.23.12)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.6 - tsx: 4.23.11 + tsx: 4.23.12 yaml: 2.9.0 - postcss-loader@8.2.1(@rspack/core@1.6.8(@swc/helpers@0.5.23))(postcss@8.5.26)(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + postcss-loader@8.2.1(@rspack/core@1.6.8(@swc/helpers@0.5.23))(postcss@8.5.26)(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: cosmiconfig: 9.0.2(typescript@5.9.3) jiti: 2.7.0 @@ -21950,7 +22326,7 @@ snapshots: semver: 7.8.5 optionalDependencies: '@rspack/core': 1.6.8(@swc/helpers@0.5.23) - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) transitivePeerDependencies: - typescript @@ -22753,14 +23129,14 @@ snapshots: sass-embedded-win32-arm64: 1.100.0 sass-embedded-win32-x64: 1.100.0 - sass-loader@16.0.8(@rspack/core@1.6.8(@swc/helpers@0.5.23))(sass-embedded@1.100.0)(sass@1.102.0)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + sass-loader@16.0.8(@rspack/core@1.6.8(@swc/helpers@0.5.23))(sass-embedded@1.100.0)(sass@1.102.0)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: neo-async: 2.6.2 optionalDependencies: '@rspack/core': 1.6.8(@swc/helpers@0.5.23) sass: 1.102.0 sass-embedded: 1.100.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) sass@1.100.0: dependencies: @@ -23153,11 +23529,11 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@5.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + source-map-loader@5.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) source-map-support@0.5.13: dependencies: @@ -23324,9 +23700,9 @@ snapshots: strip-json-comments@3.1.1: {} - style-loader@3.3.4(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + style-loader@3.3.4(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) style-to-js@1.1.21: dependencies: @@ -23400,7 +23776,7 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - tailwindcss@3.4.19(tsx@4.23.11)(yaml@2.9.0): + tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -23419,7 +23795,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.23.11)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.23.12)(yaml@2.9.0) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -23458,28 +23834,28 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.6.3 - terser-webpack-plugin@5.3.16(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)): + terser-webpack-plugin@5.3.16(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.44.1 - webpack: 5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2) optionalDependencies: '@swc/core': 1.15.47(@swc/helpers@0.5.23) - esbuild: 0.28.1 + esbuild: 0.28.2 - terser-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.26)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + terser-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.26)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.49.2 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) optionalDependencies: '@swc/core': 1.15.47(@swc/helpers@0.5.23) - esbuild: 0.28.1 + esbuild: 0.28.2 postcss: 8.5.26 terser@5.44.1: @@ -23600,12 +23976,12 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.2)(jest-util@30.4.1)(jest@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.9 - jest: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + jest: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -23618,10 +23994,10 @@ snapshots: '@jest/transform': 30.4.1 '@jest/types': 30.4.1 babel-jest: 30.4.1(@babel/core@7.29.7) - esbuild: 0.28.1 + esbuild: 0.28.2 jest-util: 30.4.1 - ts-loader@9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)): + ts-loader@9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)): dependencies: chalk: 4.1.2 enhanced-resolve: 5.18.4 @@ -23629,15 +24005,15 @@ snapshots: semver: 7.8.5 source-map: 0.7.6 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2) - ts-loader@9.6.2(loader-utils@2.0.4)(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + ts-loader@9.6.2(loader-utils@2.0.4)(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: chalk: 4.1.2 picomatch: 4.0.4 source-map: 0.7.6 typescript: 5.9.3 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) optionalDependencies: loader-utils: 2.0.4 @@ -23689,9 +24065,9 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.11: + tsx@4.23.12: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 @@ -23768,12 +24144,12 @@ snapshots: typed-assert@1.0.9: {} - typescript-eslint@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): + typescript-eslint@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.66.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: @@ -24051,7 +24427,7 @@ snapshots: dependencies: global: 4.4.0 - vite@6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.11)(yaml@2.9.0): + vite@6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -24067,12 +24443,12 @@ snapshots: sass: 1.102.0 sass-embedded: 1.100.0 terser: 5.49.2 - tsx: 4.23.11 + tsx: 4.23.12 yaml: 2.9.0 - vite@7.3.6(patch_hash=215b6585a9c92fa68c096d500f4676983200307b1e64c1321e20ccbd50bbc8f3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.97.3)(terser@5.49.2)(tsx@4.23.11)(yaml@2.9.0): + vite@7.3.6(patch_hash=215b6585a9c92fa68c096d500f4676983200307b1e64c1321e20ccbd50bbc8f3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.97.3)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0): dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.26 @@ -24086,12 +24462,12 @@ snapshots: sass: 1.97.3 sass-embedded: 1.100.0 terser: 5.49.2 - tsx: 4.23.11 + tsx: 4.23.12 yaml: 2.9.0 - vitefu@1.1.1(vite@6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.11)(yaml@2.9.0)): + vitefu@1.1.1(vite@6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0)): optionalDependencies: - vite: 6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.11)(yaml@2.9.0) + vite: 6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0) w3c-xmlserializer@5.0.0: dependencies: @@ -24140,7 +24516,7 @@ snapshots: webidl-conversions@7.0.0: {} - webpack-dev-middleware@7.4.5(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + webpack-dev-middleware@7.4.5(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: colorette: 2.0.20 memfs: 4.68.0 @@ -24149,9 +24525,9 @@ snapshots: range-parser: 1.3.0 schema-utils: 4.3.3 optionalDependencies: - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) - webpack-dev-server@5.2.6(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + webpack-dev-server@5.2.6(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -24179,10 +24555,10 @@ snapshots: serve-index: 1.9.2 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.5(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + webpack-dev-middleware: 7.4.5(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) ws: 8.21.3 optionalDependencies: - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) transitivePeerDependencies: - bufferutil - debug @@ -24201,12 +24577,12 @@ snapshots: webpack-sources@3.5.1: {} - webpack-subresource-integrity@5.1.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)): + webpack-subresource-integrity@5.1.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: typed-assert: 1.0.9 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) - webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1): + webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -24230,7 +24606,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.3.16(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)) + terser-webpack-plugin: 5.3.16(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)) watchpack: 2.5.0 webpack-sources: 3.3.3 transitivePeerDependencies: @@ -24238,7 +24614,7 @@ snapshots: - esbuild - uglify-js - webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.26): + webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.26): dependencies: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 @@ -24254,7 +24630,7 @@ snapshots: events: 3.3.0 graceful-fs: 4.2.11 mime-db: 1.54.0 - minimizer-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.26)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + minimizer-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.26)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 @@ -24274,7 +24650,7 @@ snapshots: - postcss - uglify-js - webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6): + webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6): dependencies: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 @@ -24290,7 +24666,7 @@ snapshots: events: 3.3.0 graceful-fs: 4.2.11 mime-db: 1.54.0 - minimizer-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.6)) + minimizer-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)) neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 From 82486220ac43ab16a75fefe513050a633b50be48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:29:00 +0200 Subject: [PATCH 2/8] chore(deps): bump github/codeql-action in the actions-minor-patch group (#1463) Bumps the actions-minor-patch group with 1 update: [github/codeql-action](https://github.com/github/codeql-action). Updates `github/codeql-action` from 4.37.6 to 4.37.7 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.6...v4.37.7) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2efe0ebf7..5f87782aa 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -44,7 +44,7 @@ jobs: # commit checked out above (the modern default); JavaScript is # interpreted, so no build step is needed before analysis. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.6 + uses: github/codeql-action/init@v4.37.7 with: languages: ${{ matrix.language }} # Excludes the localhost dev/E2E mock servers from analysis; see the @@ -56,4 +56,4 @@ jobs: # queries: ./path/to/local/query, your-org/your-repo/queries@main - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.6 + uses: github/codeql-action/analyze@v4.37.7 From c1ad4672c78a19b01e381c1148432d0b0d9d917f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:15:43 +0200 Subject: [PATCH 3/8] chore(deps-dev): bump @angular-devkit/schematics from 21.2.19 to 21.2.21 (#1460) Bumps [@angular-devkit/schematics](https://github.com/angular/angular-cli) from 21.2.19 to 21.2.21. - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.19...v21.2.21) --- updated-dependencies: - dependency-name: "@angular-devkit/schematics" dependency-version: 21.2.21 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- pnpm-lock.yaml | 55 +++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 2b19cab0e..ba6e6a76b 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,7 @@ }, "devDependencies": { "@angular-devkit/core": "21.2.19", - "@angular-devkit/schematics": "21.2.19", + "@angular-devkit/schematics": "21.2.21", "@angular-eslint/builder": "21.3.1", "@angular-eslint/eslint-plugin": "21.3.1", "@angular-eslint/eslint-plugin-template": "21.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e5b65a31..d1061d6a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,8 +173,8 @@ importers: specifier: 21.2.19 version: 21.2.19(chokidar@5.0.0) '@angular-devkit/schematics': - specifier: 21.2.19 - version: 21.2.19(chokidar@5.0.0) + specifier: 21.2.21 + version: 21.2.21(chokidar@5.0.0) '@angular-eslint/builder': specifier: 21.3.1 version: 21.3.1(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) @@ -249,7 +249,7 @@ importers: version: 16.0.1(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)) '@nx/angular': specifier: 22.7.8 - version: 22.7.8(d05e5b074e75040418244af5b15d2172) + version: 22.7.8(16d653af049c9803b64d2ea7df342a22) '@nx/devkit': specifier: 22.7.8 version: 22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) @@ -545,10 +545,23 @@ packages: chokidar: optional: true + '@angular-devkit/core@21.2.21': + resolution: {integrity: sha512-xOr6mZ00M6hgM/xltAVkPVc/Yd1a/Wa0CGecV64JTITaVaSH8p8+wXe62Xavdr3pcc9cLKKUUB4mAup4k7Kkyw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^5.0.0 + peerDependenciesMeta: + chokidar: + optional: true + '@angular-devkit/schematics@21.2.19': resolution: {integrity: sha512-AG3Fzh9wJCmKBfxUQOUWaEHMj5Gq2O+Msf1z52aDSxbVhs5/iSQcXGPv/DLdAXu7d4xmQhLouNe9Glaq2omDyw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@angular-devkit/schematics@21.2.21': + resolution: {integrity: sha512-LgTd/0CpyWSxMJLLk0X85F5RyDlJekssLlapU1C8fKqOUdWONYncTXYvZUQ+zbRAGXHcSZrst1Vm8qdXbo11Mg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@angular-eslint/builder@21.3.1': resolution: {integrity: sha512-1f1Lyp5e7OH6txiV224HaY3G1uRCj91OSKq7hT2Vw9NRw6zWFc1anBpDeLVjpL9ptUxzUGIQR5jEV54hOPayoQ==} peerDependencies: @@ -6836,6 +6849,7 @@ packages: eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -11847,6 +11861,17 @@ snapshots: optionalDependencies: chokidar: 5.0.0 + '@angular-devkit/core@21.2.21(chokidar@5.0.0)': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + jsonc-parser: 3.3.1 + picomatch: 4.0.4 + rxjs: 7.8.2 + source-map: 0.7.6 + optionalDependencies: + chokidar: 5.0.0 + '@angular-devkit/schematics@21.2.19(chokidar@5.0.0)': dependencies: '@angular-devkit/core': 21.2.19(chokidar@5.0.0) @@ -11857,6 +11882,16 @@ snapshots: transitivePeerDependencies: - chokidar + '@angular-devkit/schematics@21.2.21(chokidar@5.0.0)': + dependencies: + '@angular-devkit/core': 21.2.21(chokidar@5.0.0) + jsonc-parser: 3.3.1 + magic-string: 0.30.21 + ora: 9.3.0 + rxjs: 7.8.2 + transitivePeerDependencies: + - chokidar + '@angular-eslint/builder@21.3.1(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-devkit/architect': 0.2101.4(chokidar@5.0.0) @@ -11926,7 +11961,7 @@ snapshots: '@angular-eslint/schematics@21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.67.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-devkit/core': 21.2.19(chokidar@5.0.0) - '@angular-devkit/schematics': 21.2.19(chokidar@5.0.0) + '@angular-devkit/schematics': 21.2.21(chokidar@5.0.0) '@angular-eslint/eslint-plugin': 21.3.1(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/eslint-plugin-template': 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.67.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular/cli': 21.2.19(@types/node@20.19.9)(chokidar@5.0.0) @@ -11944,7 +11979,7 @@ snapshots: '@angular-eslint/schematics@21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-devkit/core': 21.2.19(chokidar@5.0.0) - '@angular-devkit/schematics': 21.2.19(chokidar@5.0.0) + '@angular-devkit/schematics': 21.2.21(chokidar@5.0.0) '@angular-eslint/eslint-plugin': 21.4.0(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/eslint-plugin-template': 21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular/cli': 21.2.19(@types/node@20.19.9)(chokidar@5.0.0) @@ -15078,10 +15113,10 @@ snapshots: node-gyp: 12.4.0 proc-log: 6.1.0 - '@nx/angular@22.7.8(d05e5b074e75040418244af5b15d2172)': + '@nx/angular@22.7.8(16d653af049c9803b64d2ea7df342a22)': dependencies: '@angular-devkit/core': 21.2.19(chokidar@5.0.0) - '@angular-devkit/schematics': 21.2.19(chokidar@5.0.0) + '@angular-devkit/schematics': 21.2.21(chokidar@5.0.0) '@nx/devkit': 22.7.8(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) '@nx/eslint': 22.7.8(5d8e116ecf28960b728f05bee433010d) '@nx/js': 22.7.8(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(nx@22.7.8(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@5.9.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))) @@ -15710,7 +15745,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.4 + picomatch: 4.0.5 optionalDependencies: '@parcel/watcher-android-arm64': 2.6.0 '@parcel/watcher-darwin-arm64': 2.6.0 @@ -17021,7 +17056,7 @@ snapshots: angular-eslint@21.4.0(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3): dependencies: '@angular-devkit/core': 21.2.19(chokidar@5.0.0) - '@angular-devkit/schematics': 21.2.19(chokidar@5.0.0) + '@angular-devkit/schematics': 21.2.21(chokidar@5.0.0) '@angular-eslint/builder': 21.4.0(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/eslint-plugin': 21.4.0(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/eslint-plugin-template': 21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) @@ -24010,7 +24045,7 @@ snapshots: ts-loader@9.6.2(loader-utils@2.0.4)(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6)): dependencies: chalk: 4.1.2 - picomatch: 4.0.4 + picomatch: 4.0.5 source-map: 0.7.6 typescript: 5.9.3 webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(postcss@8.5.6) From 96e235cd90b0bdd814f4a1fd69dec12ec383a823 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:56:31 +0200 Subject: [PATCH 4/8] chore(deps-dev): bump @angular/cli from 21.2.19 to 21.2.21 (#1462) Bumps [@angular/cli](https://github.com/angular/angular-cli) from 21.2.19 to 21.2.21. - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.19...v21.2.21) --- updated-dependencies: - dependency-name: "@angular/cli" dependency-version: 21.2.21 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- pnpm-lock.yaml | 166 +++++++++++++++++++++++-------------------------- 2 files changed, 80 insertions(+), 88 deletions(-) diff --git a/package.json b/package.json index ba6e6a76b..21f10cee7 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,7 @@ "@angular-eslint/schematics": "21.3.1", "@angular-eslint/template-parser": "21.3.1", "@angular/build": "21.2.19", - "@angular/cli": "21.2.19", + "@angular/cli": "21.2.21", "@angular/common": "21.2.19", "@angular/compiler": "21.2.19", "@angular/compiler-cli": "21.2.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1061d6a0..d47df0dd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,7 +177,7 @@ importers: version: 21.2.21(chokidar@5.0.0) '@angular-eslint/builder': specifier: 21.3.1 - version: 21.3.1(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + version: 21.3.1(@angular/cli@21.2.21(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/eslint-plugin': specifier: 21.3.1 version: 21.3.1(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) @@ -186,7 +186,7 @@ importers: version: 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.67.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/schematics': specifier: 21.3.1 - version: 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.67.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + version: 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.21(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.67.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/template-parser': specifier: 21.3.1 version: 21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) @@ -194,8 +194,8 @@ importers: specifier: 21.2.19 version: 21.2.19(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(typescript@5.9.3))(@angular/compiler@21.2.19)(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.19(@angular/animations@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/service-worker@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(chokidar@5.0.0)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.100.0)(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0))(terser@5.49.2)(tslib@2.8.1)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) '@angular/cli': - specifier: 21.2.19 - version: 21.2.19(@types/node@20.19.9)(chokidar@5.0.0) + specifier: 21.2.21 + version: 21.2.21(@types/node@20.19.9)(chokidar@5.0.0) '@angular/compiler-cli': specifier: 21.2.19 version: 21.2.19(@angular/compiler@21.2.19)(typescript@5.9.3) @@ -327,7 +327,7 @@ importers: version: 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) angular-eslint: specifier: 21.4.0 - version: 21.4.0(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3) + version: 21.4.0(@angular/cli@21.2.21(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3) astro: specifier: 5.18.1 version: 5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.100.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) @@ -513,8 +513,8 @@ packages: engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular-devkit/architect@0.2102.20': - resolution: {integrity: sha512-s7wPFCMrt9mWMr+NWs4T8849snnOE4DcmLb9Set7KtbhV6Z8E7ZASs/wqPOS3KPxLjxTqM6CnkwKhc3Th6DniA==} + '@angular-devkit/architect@0.2102.21': + resolution: {integrity: sha512-WqITVviALevNpCQoI50e3YY9qNOQT+gqTow/4FhWsxlAJFMb5uyilFpVz2hGYWPgosF3OAKmT1h9r+Jm9S6SUQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true @@ -536,15 +536,6 @@ packages: chokidar: optional: true - '@angular-devkit/core@21.2.20': - resolution: {integrity: sha512-QViRAYFj3jcElWND79hM6y4vTSYhMlJSOjy9nby9JsQaPgetkf039jI2u9Lp+PduE6lysewzf85d1UGsm3eI0A==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} - peerDependencies: - chokidar: ^5.0.0 - peerDependenciesMeta: - chokidar: - optional: true - '@angular-devkit/core@21.2.21': resolution: {integrity: sha512-xOr6mZ00M6hgM/xltAVkPVc/Yd1a/Wa0CGecV64JTITaVaSH8p8+wXe62Xavdr3pcc9cLKKUUB4mAup4k7Kkyw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -711,8 +702,8 @@ packages: '@angular/platform-browser': ^21.0.0 || ^22.0.0 rxjs: ^6.5.3 || ^7.4.0 - '@angular/cli@21.2.19': - resolution: {integrity: sha512-i78NzvoNonAY17QgzSmqrYnXHmEfraLv4wZ/o/m3efxuz61ZJ+5X/PsCeAhbwBvQfRrPRQaJV2tK9vGjHa+U6w==} + '@angular/cli@21.2.21': + resolution: {integrity: sha512-9TqWGpguYGpAsycSYX/Ji6csuPrEntdd+D2iK0TWIVQ1APCma7UmBgI1ltcATQNezxWiLTiJIMIOEuPG+cIVnw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true @@ -3263,8 +3254,8 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} - '@modelcontextprotocol/sdk@1.26.0': - resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -4500,6 +4491,10 @@ packages: resolution: {integrity: sha512-eL+UU9eizoadhDB4YEctRmmo0A5iwrSmGzeuEa6akrq8nLGVWM8zO91HTJutkPqGQjelF+UOiOShsQSZAU9SIQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@schematics/angular@21.2.21': + resolution: {integrity: sha512-ZiR5CcDMBI+0TkP4WeazhJmu1SdIq81VvO9CbXEHBO9KQWTtuE0EQCnzQkpIo4Dyh6jDRpA6sA3gkh79vW4aNQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@shikijs/core@3.22.0': resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==} @@ -5379,6 +5374,10 @@ packages: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -6931,8 +6930,8 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.1.0: - resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} eventsource@3.0.7: @@ -7187,10 +7186,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} - engines: {node: '>=18'} - get-east-asian-width@1.6.0: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} @@ -7580,8 +7575,8 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} - ip-address@10.4.0: - resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -8061,8 +8056,8 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.8: - resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} + jose@6.2.9: + resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -11821,9 +11816,9 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular-devkit/architect@0.2102.20(chokidar@5.0.0)': + '@angular-devkit/architect@0.2102.21(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 21.2.20(chokidar@5.0.0) + '@angular-devkit/core': 21.2.21(chokidar@5.0.0) rxjs: 7.8.2 transitivePeerDependencies: - chokidar @@ -11850,17 +11845,6 @@ snapshots: optionalDependencies: chokidar: 5.0.0 - '@angular-devkit/core@21.2.20(chokidar@5.0.0)': - dependencies: - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - jsonc-parser: 3.3.1 - picomatch: 4.0.4 - rxjs: 7.8.2 - source-map: 0.7.6 - optionalDependencies: - chokidar: 5.0.0 - '@angular-devkit/core@21.2.21(chokidar@5.0.0)': dependencies: ajv: 8.18.0 @@ -11892,21 +11876,21 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular-eslint/builder@21.3.1(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/builder@21.3.1(@angular/cli@21.2.21(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-devkit/architect': 0.2101.4(chokidar@5.0.0) '@angular-devkit/core': 21.2.19(chokidar@5.0.0) - '@angular/cli': 21.2.19(@types/node@20.19.9)(chokidar@5.0.0) + '@angular/cli': 21.2.21(@types/node@20.19.9)(chokidar@5.0.0) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - chokidar - '@angular-eslint/builder@21.4.0(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/builder@21.4.0(@angular/cli@21.2.21(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@angular-devkit/architect': 0.2102.20(chokidar@5.0.0) + '@angular-devkit/architect': 0.2102.21(chokidar@5.0.0) '@angular-devkit/core': 21.2.19(chokidar@5.0.0) - '@angular/cli': 21.2.19(@types/node@20.19.9)(chokidar@5.0.0) + '@angular/cli': 21.2.21(@types/node@20.19.9)(chokidar@5.0.0) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: @@ -11958,13 +11942,13 @@ snapshots: ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 - '@angular-eslint/schematics@21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.67.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/schematics@21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.21(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.67.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-devkit/core': 21.2.19(chokidar@5.0.0) '@angular-devkit/schematics': 21.2.21(chokidar@5.0.0) '@angular-eslint/eslint-plugin': 21.3.1(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/eslint-plugin-template': 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.67.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular/cli': 21.2.19(@types/node@20.19.9)(chokidar@5.0.0) + '@angular/cli': 21.2.21(@types/node@20.19.9)(chokidar@5.0.0) ignore: 7.0.5 semver: 7.7.4 strip-json-comments: 3.1.1 @@ -11976,13 +11960,13 @@ snapshots: - eslint - typescript - '@angular-eslint/schematics@21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/schematics@21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.21(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-devkit/core': 21.2.19(chokidar@5.0.0) '@angular-devkit/schematics': 21.2.21(chokidar@5.0.0) '@angular-eslint/eslint-plugin': 21.4.0(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/eslint-plugin-template': 21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular/cli': 21.2.19(@types/node@20.19.9)(chokidar@5.0.0) + '@angular/cli': 21.2.21(@types/node@20.19.9)(chokidar@5.0.0) ignore: 7.0.5 semver: 7.7.4 strip-json-comments: 3.1.1 @@ -12092,15 +12076,15 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 - '@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0)': + '@angular/cli@21.2.21(@types/node@20.19.9)(chokidar@5.0.0)': dependencies: - '@angular-devkit/architect': 0.2102.19(chokidar@5.0.0) - '@angular-devkit/core': 21.2.19(chokidar@5.0.0) - '@angular-devkit/schematics': 21.2.19(chokidar@5.0.0) + '@angular-devkit/architect': 0.2102.21(chokidar@5.0.0) + '@angular-devkit/core': 21.2.21(chokidar@5.0.0) + '@angular-devkit/schematics': 21.2.21(chokidar@5.0.0) '@inquirer/prompts': 7.10.1(@types/node@20.19.9) '@listr2/prompt-adapter-inquirer': 3.0.5(@inquirer/prompts@7.10.1(@types/node@20.19.9))(@types/node@20.19.9)(listr2@9.0.5) - '@modelcontextprotocol/sdk': 1.26.0(zod@4.3.6) - '@schematics/angular': 21.2.19(chokidar@5.0.0) + '@modelcontextprotocol/sdk': 1.30.0(zod@4.3.6) + '@schematics/angular': 21.2.21(chokidar@5.0.0) '@yarnpkg/lockfile': 1.1.0 algoliasearch: 5.48.1 ini: 6.0.0 @@ -14605,7 +14589,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)': + '@modelcontextprotocol/sdk@1.30.0(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.14) ajv: 8.18.0 @@ -14614,11 +14598,11 @@ snapshots: cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.1.0 + eventsource-parser: 3.1.1 express: 5.2.1 express-rate-limit: 8.3.0(express@5.2.1) hono: 4.12.14 - jose: 6.2.8 + jose: 6.2.9 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -15069,7 +15053,7 @@ snapshots: '@npmcli/fs@5.0.0': dependencies: - semver: 7.7.4 + semver: 7.8.5 '@npmcli/git@7.0.2': dependencies: @@ -15079,7 +15063,7 @@ snapshots: lru-cache: 11.5.2 npm-pick-manifest: 11.0.3 proc-log: 6.1.0 - semver: 7.7.4 + semver: 7.8.5 which: 6.0.1 '@npmcli/installed-package-contents@4.0.0': @@ -15096,7 +15080,7 @@ snapshots: hosted-git-info: 9.0.3 json-parse-even-better-errors: 5.0.0 proc-log: 6.1.0 - semver: 7.7.4 + semver: 7.8.5 spdx-expression-parse: 4.0.0 '@npmcli/promise-spawn@9.0.1': @@ -16121,6 +16105,14 @@ snapshots: transitivePeerDependencies: - chokidar + '@schematics/angular@21.2.21(chokidar@5.0.0)': + dependencies: + '@angular-devkit/core': 21.2.21(chokidar@5.0.0) + '@angular-devkit/schematics': 21.2.21(chokidar@5.0.0) + jsonc-parser: 3.3.1 + transitivePeerDependencies: + - chokidar + '@shikijs/core@3.22.0': dependencies: '@shikijs/types': 3.22.0 @@ -17053,16 +17045,16 @@ snapshots: '@algolia/requester-fetch': 5.48.1 '@algolia/requester-node-http': 5.48.1 - angular-eslint@21.4.0(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3): + angular-eslint@21.4.0(@angular/cli@21.2.21(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3): dependencies: '@angular-devkit/core': 21.2.19(chokidar@5.0.0) '@angular-devkit/schematics': 21.2.21(chokidar@5.0.0) - '@angular-eslint/builder': 21.4.0(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/builder': 21.4.0(@angular/cli@21.2.21(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/eslint-plugin': 21.4.0(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/eslint-plugin-template': 21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular-eslint/schematics': 21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.19(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/schematics': 21.4.0(@angular-eslint/template-parser@21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.21(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.66.0)(@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/template-parser': 21.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular/cli': 21.2.19(@types/node@20.19.9)(chokidar@5.0.0) + '@angular/cli': 21.2.21(@types/node@20.19.9)(chokidar@5.0.0) '@typescript-eslint/types': 8.66.0 '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) @@ -17099,6 +17091,8 @@ snapshots: ansi-regex@6.2.2: {} + ansi-regex@6.3.0: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -19011,11 +19005,11 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.1.0: {} + eventsource-parser@3.1.1: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.1.0 + eventsource-parser: 3.1.1 execa@5.1.1: dependencies: @@ -19389,8 +19383,6 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.4.0: {} - get-east-asian-width@1.6.0: {} get-intrinsic@1.3.0: @@ -19475,7 +19467,7 @@ snapshots: es6-error: 4.1.1 matcher: 3.0.0 roarr: 2.15.4 - semver: 7.7.4 + semver: 7.8.5 serialize-error: 7.0.1 optional: true @@ -19927,7 +19919,7 @@ snapshots: ip-address@10.1.0: {} - ip-address@10.4.0: {} + ip-address@10.5.0: {} ipaddr.js@1.9.1: {} @@ -20148,7 +20140,7 @@ snapshots: '@babel/parser': 7.29.8 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 - semver: 7.7.4 + semver: 7.8.5 transitivePeerDependencies: - supports-color @@ -20585,7 +20577,7 @@ snapshots: jiti@2.7.0: {} - jose@6.2.8: {} + jose@6.2.9: {} js-tokens@4.0.0: {} @@ -21621,7 +21613,7 @@ snapshots: node-abi@4.33.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 node-abort-controller@3.1.1: {} @@ -21635,7 +21627,7 @@ snapshots: node-api-version@0.2.1: dependencies: - semver: 7.7.4 + semver: 7.8.5 node-fetch-native@1.6.7: {} @@ -21659,7 +21651,7 @@ snapshots: graceful-fs: 4.2.11 nopt: 9.0.0 proc-log: 6.1.0 - semver: 7.7.4 + semver: 7.8.5 tar: 7.5.22 tinyglobby: 0.2.17 undici: 6.28.0 @@ -21687,7 +21679,7 @@ snapshots: npm-install-checks@8.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 npm-normalize-package-bin@5.0.0: {} @@ -21695,7 +21687,7 @@ snapshots: dependencies: hosted-git-info: 9.0.3 proc-log: 6.1.0 - semver: 7.7.4 + semver: 7.8.5 validate-npm-package-name: 7.0.2 npm-packlist@10.0.4: @@ -21708,7 +21700,7 @@ snapshots: npm-install-checks: 8.0.0 npm-normalize-package-bin: 5.0.0 npm-package-arg: 13.0.2 - semver: 7.7.4 + semver: 7.8.5 npm-registry-fetch@19.1.1: dependencies: @@ -22655,7 +22647,7 @@ snapshots: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.7.1 + iconv-lite: 0.7.3 unpipe: 1.0.0 react-is@18.3.1: {} @@ -23366,7 +23358,7 @@ snapshots: dependencies: '@img/colour': 1.0.0 detect-libc: 2.1.2 - semver: 7.7.3 + semver: 7.8.5 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -23559,7 +23551,7 @@ snapshots: socks@2.8.9: dependencies: - ip-address: 10.4.0 + ip-address: 10.5.0 smart-buffer: 4.2.0 source-map-js@1.2.1: {} @@ -23671,7 +23663,7 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.4.0 + get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 string-width@8.2.2: @@ -23725,7 +23717,7 @@ snapshots: strip-ansi@7.2.0: dependencies: - ansi-regex: 6.2.2 + ansi-regex: 6.3.0 strip-bom@3.0.0: {} From 242640e8c6264bcfcd2181b82e83e1703e08f922 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:26:02 +0200 Subject: [PATCH 5/8] chore(deps): bump ngx-indexed-db from 21.0.0 to 22.0.0 (#1472) Coordinated replacement for the Dependabot branch: the bot updated only the root package.json, leaving the ^21 specifier in libs/shared/interfaces, which failed the @nx/dependency-checks lint rule. Co-authored-by: 4gray Co-authored-by: Claude Fable 5 --- .changes/deps-ngx-indexed-db-22.md | 6 ++++++ libs/shared/interfaces/package.json | 2 +- package.json | 2 +- pnpm-lock.yaml | 15 +++++++-------- 4 files changed, 15 insertions(+), 10 deletions(-) create mode 100644 .changes/deps-ngx-indexed-db-22.md diff --git a/.changes/deps-ngx-indexed-db-22.md b/.changes/deps-ngx-indexed-db-22.md new file mode 100644 index 000000000..b8ed6b335 --- /dev/null +++ b/.changes/deps-ngx-indexed-db-22.md @@ -0,0 +1,6 @@ +--- +type: internal +area: deps +--- + +Updated ngx-indexed-db (the PWA's IndexedDB storage library) from 21 to 22. No behavior change. diff --git a/libs/shared/interfaces/package.json b/libs/shared/interfaces/package.json index ae833112e..519b83807 100644 --- a/libs/shared/interfaces/package.json +++ b/libs/shared/interfaces/package.json @@ -6,7 +6,7 @@ "main": "./src/index.js", "types": "./src/index.d.ts", "dependencies": { - "ngx-indexed-db": "^21.0.0", + "ngx-indexed-db": "^22.0.0", "tslib": "^2.3.0" } } diff --git a/package.json b/package.json index 21f10cee7..d4eeb787c 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "marked": "18.0.9", "mpegts.js": "1.8.1", "ms": "2.1.3", - "ngx-indexed-db": "21.0.0", + "ngx-indexed-db": "22.0.0", "ngx-skeleton-loader": "11.3.0", "rxjs": "7.8.2", "saxes": "6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d47df0dd8..9f0c6d5ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,8 +142,8 @@ importers: specifier: 2.1.3 version: 2.1.3 ngx-indexed-db: - specifier: 21.0.0 - version: 21.0.0(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)) + specifier: 22.0.0 + version: 22.0.0(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)) ngx-skeleton-loader: specifier: 11.3.0 version: 11.3.0(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)) @@ -8806,11 +8806,11 @@ packages: '@angular/forms': 5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19 || 20.0.0-alpha - 20 || 21.0.0-alpha - 21 || 22.0.0-alpha - 22 '@angular/platform-browser': 5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19 || 20.0.0-alpha - 20 || 21.0.0-alpha - 21 || 22.0.0-alpha - 22 - ngx-indexed-db@21.0.0: - resolution: {integrity: sha512-781n0P4ZWt0CegInpvQ7WB2Z9Z9A4Pi3GKOUuAfsKFof4ZSv85g05+IXkSx+h3jBIibCuNWcQd77VLj84xLBQA==} + ngx-indexed-db@22.0.0: + resolution: {integrity: sha512-h8I1w9U/X5swG1Py3Ir9dPjxEVpE59aitIU+aP0PoQ8qYGWw+/7VYm7t6EJmvWHWedIczH1XBcqG65b/fONY6Q==} peerDependencies: - '@angular/common': '>=10.0.6 < 21.0.0' - '@angular/core': '>=10.0.6 < 21.0.0' + '@angular/common': '>=10.0.6 < 22.0.0' + '@angular/core': '>=10.0.6 < 22.0.0' ngx-skeleton-loader@11.3.0: resolution: {integrity: sha512-MLm5shgXGiCA1W5NEqct6glBFx2AEgEKbk8pDyY15BsZ2zTGUwa5jw4pe6nJdrCj6xcl/d9oFTinQHrO0q+3RA==} @@ -10246,7 +10246,6 @@ packages: shaka-player@5.2.4: resolution: {integrity: sha512-vf81av2EIcb03jRpeeZBhrPT3PMyggXnZA9k4sxGBxpr/H6v0iEL6b51T2Kwz5YNrQG/yDYoadgBW+oW40LeoA==} - engines: {node: '>=18'} shallow-clone@3.0.1: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} @@ -21595,7 +21594,7 @@ snapshots: '@angular/forms': 21.2.19(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.19(@angular/animations@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) '@angular/platform-browser': 21.2.19(@angular/animations@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)) - ngx-indexed-db@21.0.0(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)): + ngx-indexed-db@22.0.0(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2)): dependencies: '@angular/common': 21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) '@angular/core': 21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)(zone.js@0.16.2) From 50871e581fb7e27071980a6d65698afc71cd11be Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:39:39 +0200 Subject: [PATCH 6/8] feat(playback): add quality selection to shared player controls (#1470) * feat(playback): add quality selection to shared player controls Adds a per-session video quality menu (Auto + "1080p"-style levels) to the shared player-controls layer, mirroring the audio-track pattern: - Contract: qualityLevels capability, qualityLevels/qualityAutoEnabled state, setQualityLevel command with AUTO_QUALITY_LEVEL_ID (-1) restoring ABR. - hls.js (HTML5/ArtPlayer via the neutral source bridge): levels with list-index ids, smooth switching through nextLevel, selection read from manualLevel; refresh events extended with MANIFEST_PARSED, LEVELS_UPDATED, LEVEL_SWITCHED. - Shaka (DASH): variant tracks filtered to the active audio language, ABR disabled before selectVariantTrack; manual state keyed to the exact player instance so a session restart never shows a stale selection. - Video.js: new VjsQualityLevels over videojs-contrib-quality-levels (manual = exactly one enabled level, auto = all enabled, derived statelessly). - Embedded MPV and external players report the capability false. The capability derives from the manifest (advertised only for >1 video rendition), nothing persists to Settings, and the menu rides the default-off webPlayerSharedControls rollout gate. Labels come from one shared helper so all engines render the same vocabulary. QUALITY/QUALITY_AUTO keys added to all 19 i18n files. Co-Authored-By: Claude Fable 5 * fix(playback): pin DASH quality candidates to the active audio stream Review findings on #1470: - Shaka quality candidates now match the active variant's exact audioId (language fallback only when Shaka reports none), so a DASH manifest with same-language audio tracks (main vs. commentary, stereo vs. 5.1) can no longer switch the audio track or show duplicate levels when a quality is picked. Regression test added. - Mirror the quality-selection contract into AGENTS.md's Shared Player Controls section, which must stay in sync with CLAUDE.md. Co-Authored-By: Claude Fable 5 * fix(playback): track Video.js manual quality intent explicitly Codex re-review finding on #1470: VHS flips a rendition's `enabled` flag off itself when it temporarily excludes failing renditions, so inferring the manual/auto mode from the enabled count could report a manual selection the user never made once exclusions leave a single survivor. VjsQualityLevels now records the picked level object as explicit manual intent: error exclusions read as auto, a picked level that leaves the list reverts to auto, and the bridge resets the intent on every new source. Regression tests added. Co-Authored-By: Claude Fable 5 * fix(playback): re-enable surviving renditions when the picked level is removed Codex follow-up on #1470: dropping manual intent when the picked QualityLevel leaves the list reverted the UI to auto but left the surviving renditions disabled by the earlier manual pick, pinning VHS with no selectable rendition. Reverting to auto now re-enables every remaining level, both on the removal event and lazily from the state read. Regression tests added. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: 4gray Co-authored-by: Claude Fable 5 --- .changes/playback-quality-selection.md | 10 + AGENTS.md | 9 + CLAUDE.md | 2 +- apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/hu.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + docs/architecture/player-controls-contract.md | 67 ++++- ...t-player.component.shared-controls.spec.ts | 2 + .../embedded-mpv-controls.adapter.spec.ts | 2 + .../embedded-mpv-controls.adapter.ts | 4 + .../html-video-player-controls.hls.spec.ts | 77 +++++ ...tml-video-player-controls.spec-fixtures.ts | 26 ++ ....component.shared-controls.sources.spec.ts | 4 +- .../controls-menu-selection.spec.ts | 10 + .../controls-menu-selection.ts | 6 +- .../controls-menu-state.spec.ts | 18 +- .../player-controls/controls-menu-state.ts | 10 + .../player-controls/controls-view-model.ts | 4 + .../player-controls-defaults.spec.ts | 8 + .../player-controls-defaults.ts | 3 + ...player-controls.component.contract.spec.ts | 58 ++++ .../player-controls.component.html | 77 +++++ ...er-controls.component.interactions.spec.ts | 1 + ...yer-controls.component.media-title.spec.ts | 1 + ...trols.component.picture-in-picture.spec.ts | 1 + .../player-controls.component.spec.ts | 1 + .../player-controls.component.surface.spec.ts | 1 + ...player-controls.component.timeline.spec.ts | 1 + .../player-controls.component.ts | 5 +- .../player-controls/player-controls.model.ts | 16 ++ ...web-video-controls.adapter.quality.spec.ts | 101 +++++++ .../web-video-controls.adapter.ts | 15 + .../lib/shaka-engine/shaka-module.types.ts | 26 ++ .../shaka-engine/shaka-player-test-double.ts | 20 ++ .../vjs-player/vjs-player-controls.bridge.ts | 13 + .../src/lib/vjs-player/vjs-player.types.ts | 17 ++ .../lib/vjs-player/vjs-quality-levels.spec.ts | 269 ++++++++++++++++++ .../src/lib/vjs-player/vjs-quality-levels.ts | 162 +++++++++++ .../quality-level-labels.spec.ts | 58 ++++ .../web-video-support/quality-level-labels.ts | 67 +++++ .../web-video-hls-controls.ts | 49 +++- .../web-video-shaka-controls.spec.ts | 199 +++++++++++++ .../web-video-shaka-controls.ts | 102 ++++++- .../web-video-source-controls.bridge.ts | 3 + .../web-video-source-tracks.ts | 29 ++ 61 files changed, 1582 insertions(+), 10 deletions(-) create mode 100644 .changes/playback-quality-selection.md create mode 100644 libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.quality.spec.ts create mode 100644 libs/ui/playback/src/lib/vjs-player/vjs-quality-levels.spec.ts create mode 100644 libs/ui/playback/src/lib/vjs-player/vjs-quality-levels.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/quality-level-labels.spec.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/quality-level-labels.ts diff --git a/.changes/playback-quality-selection.md b/.changes/playback-quality-selection.md new file mode 100644 index 000000000..3e62cf2ef --- /dev/null +++ b/.changes/playback-quality-selection.md @@ -0,0 +1,10 @@ +--- +type: feature +area: playback +--- + +The built-in web players' shared controls gain a quality menu: multi-bitrate +HLS and DASH streams can be pinned to a specific rendition ("1080p", "720p", …) +or left on Auto, which keeps the player's adaptive selection. The menu appears +only when the stream actually offers more than one quality, and the choice +lasts for the current playback session. diff --git a/AGENTS.md b/AGENTS.md index bce27e7e8..1800d84d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,6 +211,15 @@ Key files: `subtitleDisplay` would silently override whatever the vendor menu picks. For DASH the seed happens in `ShakaVideoSession.start()` after the manifest loads, so the helper only stops re-suppressing afterwards. +- Shared controls include a per-session quality menu (Auto + "1080p"-style + levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR). The + capability derives from the manifest — advertised only when the source + exposes >1 video rendition (multi-variant HLS via hls.js + `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the + active variant's exact audio stream (`audioId`, language fallback) with ABR + toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — + so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to + Settings, and Embedded MPV/external players report the capability false. - Embedded MPV ignores the web-player preference. Frame-copy always uses shared DOM controls through its component-scoped `EmbeddedMpvControlsAdapter`, while native-view retains the legacy compositor-safe dock and external MPV/VLC diff --git a/CLAUDE.md b/CLAUDE.md index 2f8892bb7..37afba79e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1101,7 +1101,7 @@ engine` (restart required) or helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime contracts: `docs/architecture/embedded-mpv-native.md` and `tools/embedded-mpv/README.md`. -- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so ranked recovery actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation — but the playback keyboard shortcuts (Space/K, F, arrow seek/volume, M) still work: each vendor-chrome player attaches `LegacyPlayerShortcuts` (a wrapper over the same `ControlsShortcuts` arbitration/ignore rules) with engine-specific command wiring (`html-video-legacy-shortcuts.ts`, `vjs-legacy-shortcuts.ts`, `art-player-legacy-shortcuts.ts`); seek is gated on authoritative `isLive` plus a finite positive duration, `interactionEnabled` (visible playback diagnostic) disables the keys, and the legacy ArtPlayer chrome passes `hotkey: false` because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would double-handle every key (its lost Escape-exits-`fullscreenWeb` behavior is restored by the wiring). `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendor menu, and DASH is seeded by `ShakaVideoSession.start()` after the manifest loads. `WebPlayerViewComponent` reads it from `SettingsStore` instead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract: `docs/architecture/player-controls-contract.md`. +- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so ranked recovery actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation — but the playback keyboard shortcuts (Space/K, F, arrow seek/volume, M) still work: each vendor-chrome player attaches `LegacyPlayerShortcuts` (a wrapper over the same `ControlsShortcuts` arbitration/ignore rules) with engine-specific command wiring (`html-video-legacy-shortcuts.ts`, `vjs-legacy-shortcuts.ts`, `art-player-legacy-shortcuts.ts`); seek is gated on authoritative `isLive` plus a finite positive duration, `interactionEnabled` (visible playback diagnostic) disables the keys, and the legacy ArtPlayer chrome passes `hotkey: false` because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would double-handle every key (its lost Escape-exits-`fullscreenWeb` behavior is restored by the wiring). `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendor menu, and DASH is seeded by `ShakaVideoSession.start()` after the manifest loads. `WebPlayerViewComponent` reads it from `SettingsStore` instead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract: `docs/architecture/player-controls-contract.md`. - Shared web picture-in-picture stays inside that default-off rollout. `PlayerController` exposes capability `pictureInPicture`, state `pictureInPictureActive`/`canPictureInPicture`, and command diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 5bdb8539c..f05901cc3 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "مسارات الصوت", "SUBTITLES": "الترجمات", "SUBTITLES_OFF": "إيقاف", + "QUALITY": "الجودة", + "QUALITY_AUTO": "تلقائي", "TRACK_DEFAULT": "افتراضي", "AUDIO_TRACK_FALLBACK": "صوت {{index}}", "SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 25494236c..5bf907456 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "مسارات الصوت", "SUBTITLES": "الترجمة", "SUBTITLES_OFF": "معطلة", + "QUALITY": "الجودة", + "QUALITY_AUTO": "تلقائي", "TRACK_DEFAULT": "افتراضي", "AUDIO_TRACK_FALLBACK": "صوت {{index}}", "SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index aee725a4f..f115fa435 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Аўдыядарожкі", "SUBTITLES": "Субтытры", "SUBTITLES_OFF": "Выкл.", + "QUALITY": "Якасць", + "QUALITY_AUTO": "Аўта", "TRACK_DEFAULT": "Па змаўчанні", "AUDIO_TRACK_FALLBACK": "Аўдыя {{index}}", "SUBTITLE_TRACK_FALLBACK": "Субтытры {{index}}", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index a3ad928c6..94aba1023 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Tonspuren", "SUBTITLES": "Untertitel", "SUBTITLES_OFF": "Aus", + "QUALITY": "Qualität", + "QUALITY_AUTO": "Automatisch", "TRACK_DEFAULT": "Standard", "AUDIO_TRACK_FALLBACK": "Audio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Untertitel {{index}}", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index a289814ff..5fc9a209e 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Κομμάτια ήχου", "SUBTITLES": "Υπότιτλοι", "SUBTITLES_OFF": "Ανενεργοί", + "QUALITY": "Ποιότητα", + "QUALITY_AUTO": "Αυτόματη", "TRACK_DEFAULT": "Προεπιλογή", "AUDIO_TRACK_FALLBACK": "Ήχος {{index}}", "SUBTITLE_TRACK_FALLBACK": "Υπότιτλος {{index}}", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 7c1ea9b9b..ab0c8f2e4 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Audio tracks", "SUBTITLES": "Subtitles", "SUBTITLES_OFF": "Off", + "QUALITY": "Quality", + "QUALITY_AUTO": "Auto", "TRACK_DEFAULT": "Default", "AUDIO_TRACK_FALLBACK": "Audio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Subtitle {{index}}", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 467f9df8e..d711a5001 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Pistas de audio", "SUBTITLES": "Subtítulos", "SUBTITLES_OFF": "Desactivados", + "QUALITY": "Calidad", + "QUALITY_AUTO": "Automática", "TRACK_DEFAULT": "Predeterminada", "AUDIO_TRACK_FALLBACK": "Audio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Subtítulo {{index}}", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 548b4652a..b25aa2bbd 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Pistes audio", "SUBTITLES": "Sous-titres", "SUBTITLES_OFF": "Désactivés", + "QUALITY": "Qualité", + "QUALITY_AUTO": "Auto", "TRACK_DEFAULT": "Par défaut", "AUDIO_TRACK_FALLBACK": "Audio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Sous-titre {{index}}", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 0a4a6c21a..0ff554239 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Hangsávok", "SUBTITLES": "Feliratok", "SUBTITLES_OFF": "Kikapcsolva", + "QUALITY": "Minőség", + "QUALITY_AUTO": "Automatikus", "TRACK_DEFAULT": "Alapértelmezett", "AUDIO_TRACK_FALLBACK": "{{index}}. hangsáv", "SUBTITLE_TRACK_FALLBACK": "{{index}}. feliratsáv", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index bab2ca47e..0619dc614 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Tracce audio", "SUBTITLES": "Sottotitoli", "SUBTITLES_OFF": "Disattivati", + "QUALITY": "Qualità", + "QUALITY_AUTO": "Automatica", "TRACK_DEFAULT": "Predefinita", "AUDIO_TRACK_FALLBACK": "Audio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Sottotitoli {{index}}", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index bf13d04bd..1c349bc67 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "音声トラック", "SUBTITLES": "字幕", "SUBTITLES_OFF": "オフ", + "QUALITY": "画質", + "QUALITY_AUTO": "自動", "TRACK_DEFAULT": "デフォルト", "AUDIO_TRACK_FALLBACK": "音声 {{index}}", "SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index e804ad942..7a2ef66ea 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "오디오 트랙", "SUBTITLES": "자막", "SUBTITLES_OFF": "끄기", + "QUALITY": "화질", + "QUALITY_AUTO": "자동", "TRACK_DEFAULT": "기본", "AUDIO_TRACK_FALLBACK": "오디오 {{index}}", "SUBTITLE_TRACK_FALLBACK": "자막 {{index}}", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 30f342d11..1e9e71774 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Audiosporen", "SUBTITLES": "Ondertiteling", "SUBTITLES_OFF": "Uit", + "QUALITY": "Kwaliteit", + "QUALITY_AUTO": "Automatisch", "TRACK_DEFAULT": "Standaard", "AUDIO_TRACK_FALLBACK": "Audio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Ondertitel {{index}}", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 57ba589f0..51f0a92db 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Ścieżki audio", "SUBTITLES": "Napisy", "SUBTITLES_OFF": "Wyłączone", + "QUALITY": "Jakość", + "QUALITY_AUTO": "Automatycznie", "TRACK_DEFAULT": "Domyślna", "AUDIO_TRACK_FALLBACK": "Audio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Napisy {{index}}", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 1c701907d..bb07e4918 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Faixas de áudio", "SUBTITLES": "Legendas", "SUBTITLES_OFF": "Desativadas", + "QUALITY": "Qualidade", + "QUALITY_AUTO": "Automática", "TRACK_DEFAULT": "Padrão", "AUDIO_TRACK_FALLBACK": "Áudio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Legenda {{index}}", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 96b813fc0..93c0e284d 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Аудиодорожки", "SUBTITLES": "Субтитры", "SUBTITLES_OFF": "Выкл.", + "QUALITY": "Качество", + "QUALITY_AUTO": "Авто", "TRACK_DEFAULT": "По умолчанию", "AUDIO_TRACK_FALLBACK": "Аудио {{index}}", "SUBTITLE_TRACK_FALLBACK": "Субтитры {{index}}", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index f1685059f..ebc9975c4 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Ses parçaları", "SUBTITLES": "Altyazılar", "SUBTITLES_OFF": "Kapalı", + "QUALITY": "Kalite", + "QUALITY_AUTO": "Otomatik", "TRACK_DEFAULT": "Varsayılan", "AUDIO_TRACK_FALLBACK": "Ses {{index}}", "SUBTITLE_TRACK_FALLBACK": "Altyazı {{index}}", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index fe55031d2..ff0d877ab 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "音轨", "SUBTITLES": "字幕", "SUBTITLES_OFF": "关闭", + "QUALITY": "画质", + "QUALITY_AUTO": "自动", "TRACK_DEFAULT": "默认", "AUDIO_TRACK_FALLBACK": "音轨 {{index}}", "SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 38d293f2d..b4ad1eef7 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "音軌", "SUBTITLES": "字幕", "SUBTITLES_OFF": "關閉", + "QUALITY": "畫質", + "QUALITY_AUTO": "自動", "TRACK_DEFAULT": "預設", "AUDIO_TRACK_FALLBACK": "音軌 {{index}}", "SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}", diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 61e220f3b..034f17a6a 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -172,8 +172,8 @@ interface PlayerController { ### Capabilities `PlayerControlsCapabilities` contains booleans for `seek`, `volume`, -`audioTracks`, `subtitles`, `playbackSpeed`, `aspectRatio`, `recording`, -`pictureInPicture`, `fullscreen`, and `seriesNavigation`. +`audioTracks`, `subtitles`, `qualityLevels`, `playbackSpeed`, `aspectRatio`, +`recording`, `pictureInPicture`, `fullscreen`, and `seriesNavigation`. The default is all-false. An adapter enables only features that its engine and current runtime support. Capability flags primarily control whether optional UI @@ -188,6 +188,7 @@ is rendered; state such as `canSeek`, `canPreviousEpisode`, and - current position, optional duration, live/VOD classification, and seekability; - volume; - pre-labelled audio/subtitle tracks and subtitle-enabled state; +- pre-labelled quality levels and the ABR/auto flag; - playback speed and aspect-ratio selections/presets; - recording state; - picture-in-picture active state and runtime availability; and @@ -208,6 +209,7 @@ owner. - `seekTo` / `seekBy` - `setVolume` - `setAudioTrack` / `setSubtitleTrack` +- `setQualityLevel` (`AUTO_QUALITY_LEVEL_ID` = `-1` re-enables auto) - `setPlaybackSpeed` - `setAspectRatio` - `toggleRecording` @@ -487,6 +489,64 @@ control chrome. Subtitle rendering in that surface is browser-dependent. AirPlay, Cast, Document Picture-in-Picture, a PiP keyboard shortcut, and an Embedded MPV popup or native mini-window are out of scope. +### Quality (bitrate/level) selection + +Quality selection is part of the shared controls and therefore rides the same +default-off `WEB_PLAYER_SHARED_CONTROLS` rollout. The contract exposes: + +- capability `qualityLevels`; +- state `qualityLevels` (pre-labelled options such as "1080p") and + `qualityAutoEnabled`; and +- command `setQualityLevel(id)`, where `AUTO_QUALITY_LEVEL_ID` (`-1`) + re-enables the engine's adaptive (ABR) selection. + +The capability derives from the manifest, not the content type: it is +advertised only when the current source exposes **more than one** video +rendition, so single-bitrate Xtream VOD files and raw MPEG-TS streams never +show the menu, while multi-variant live HLS does. The menu renders next to the +audio/subtitle menus with an Auto entry first; Auto is the default, a level +reports `selected` only while a manual choice is active, and the choice is +per-session — nothing is persisted to Settings. + +Labels come from one shared helper (`quality-level-labels.ts` in +`web-video-support/`): frame height first ("1080p"), a 16:9 projection when +only the width is known, the bitrate when no dimension is known, and a bitrate +suffix only when two levels would otherwise collide on the same label. + +Engine mechanics: + +- **hls.js** (HTML5 and ArtPlayer via the neutral source bridge): levels are + `hls.levels` with list-index ids; a manual switch assigns `hls.nextLevel` + (switches at the next fragment instead of flushing the buffer), `-1` restores + auto, and the selected level is read from the public `manualLevel`. The HLS + refresh-event list additionally observes `MANIFEST_PARSED`, + `LEVELS_UPDATED`, and `LEVEL_SWITCHED`. +- **Shaka (DASH)**: options are variant tracks pinned to the active variant's + exact audio stream — variants are audio+video combinations, and picking a + quality must not switch the audio track. The filter matches the active + variant's `audioId` when Shaka reports one (two same-language audio tracks + such as main vs. commentary share a language but never an id) and falls back + to the language only when no id is available — sorted by resolution then + bandwidth. Manual + selection disables ABR via `configure({abr: {enabled: false}})` before + `selectVariantTrack(track, true)`; the auto sentinel re-enables ABR. Manual + state is keyed to the exact player instance, so a session restart (which + creates a fresh player with ABR on) can never render a stale manual + selection. +- **Video.js**: `VjsQualityLevels` projects the videojs-contrib-quality-levels + list (registered by the component's plugin import). VHS has no manual-level + setter, so a manual selection enables exactly one level and auto re-enables + all. Manual intent is tracked explicitly by the picked level object — VHS + also flips `enabled` off for renditions it temporarily excludes after + delivery errors, so counting enabled levels would misreport a manual + selection. A picked level that leaves the list, a source change, and + `clear()` all revert to auto. A missing or throwing plugin degrades to no + capability. +- **Embedded MPV and external players**: `qualityLevels` stays false — + single-program transport streams have no rendition list to offer and no HLS + level API is surfaced there. `EmbeddedMpvControlsAdapter.setQualityLevel` is + a no-op. + Native media events refresh the adapter automatically. An engine host must call the public `refresh()` hook after engine-specific getters change without a corresponding media event, including track lists, corrected duration, @@ -705,8 +765,10 @@ The neutral web-video source support shared by HTML5 and ArtPlayer lives in: ```text libs/ui/playback/src/lib/web-video-support/ +├── quality-level-labels.ts ├── web-video-hls-controls.ts ├── web-video-native-text-tracks.ts +├── web-video-shaka-controls.ts ├── web-video-source-tracks.ts └── web-video-source-controls.bridge.ts ``` @@ -745,6 +807,7 @@ libs/ui/playback/src/lib/vjs-player/ ├── vjs-audio-tracks.ts ├── vjs-legacy-tracks.ts ├── vjs-mpegts-session.ts +├── vjs-quality-levels.ts ├── vjs-player-controls.bridge.ts ├── vjs-player-reset-coordinator.ts ├── vjs-player-setup.ts diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts index a780ba53e..0fc442485 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts @@ -71,6 +71,8 @@ class MockHls { SUBTITLE_TRACKS_CLEARED: 'subtitleTracksCleared', SUBTITLE_TRACK_SWITCH: 'subtitleTrackSwitch', MANIFEST_LOADING: 'manifestLoading', + LEVELS_UPDATED: 'levelsUpdated', + LEVEL_SWITCHED: 'levelSwitched', }; static isSupported = jest.fn(() => true); } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.spec.ts index a08d6e3d1..4461baafa 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.spec.ts @@ -195,6 +195,7 @@ describe('EmbeddedMpvControlsAdapter', () => { volume: true, audioTracks: true, subtitles: true, + qualityLevels: false, playbackSpeed: true, aspectRatio: true, recording: true, @@ -220,6 +221,7 @@ describe('EmbeddedMpvControlsAdapter', () => { volume: true, audioTracks: true, subtitles: false, + qualityLevels: false, playbackSpeed: false, aspectRatio: false, recording: false, diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts index cfed72188..742d19e77 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts @@ -176,6 +176,9 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { }) ), subtitlesEnabled: (session?.selectedSubtitleTrackId ?? -1) >= 0, + // MPV demuxes one program; no HLS/DASH rendition list is surfaced. + qualityLevels: [], + qualityAutoEnabled: true, playbackSpeed: session?.playbackSpeed ?? 1, speedPresets: DEFAULT_SPEED_PRESETS, aspectRatio: session?.aspectOverride ?? 'no', @@ -207,6 +210,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { setVolume: (value) => void this.controller.applyVolume(value), setAudioTrack: (id) => void this.controller.setAudioTrack(id), setSubtitleTrack: (id) => void this.controller.setSubtitleTrack(id), + setQualityLevel: () => undefined, setPlaybackSpeed: (speed) => void this.controller.setSpeed(speed), setAspectRatio: (value) => void this.controller.setAspect(value), toggleRecording: () => void this.toggleRecording(), diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.hls.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.hls.spec.ts index 7daf64d08..bae2fd685 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.hls.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.hls.spec.ts @@ -121,6 +121,80 @@ describe('HtmlVideoPlayerControlsBridge HLS tracks', () => { }); }); +describe('HtmlVideoPlayerControlsBridge HLS quality levels', () => { + it('projects labelled levels and advertises the capability above one level', () => { + const hls = new FakeHls(); + hls.levels = [ + { height: 1080, width: 1920, bitrate: 8_000_000 }, + { height: 720, width: 1280, bitrate: 4_000_000 }, + ]; + const { adapter, bridge } = bindHls(hls); + + expect(adapter.capabilities().qualityLevels).toBe(true); + expect(adapter.state().qualityAutoEnabled).toBe(true); + expect(adapter.state().qualityLevels).toEqual([ + { id: 0, label: '1080p', selected: false }, + { id: 1, label: '720p', selected: false }, + ]); + bridge.destroy(); + }); + + it('hides the capability for a single-rendition manifest', () => { + const hls = new FakeHls(); + hls.levels = [{ height: 720, bitrate: 2_000_000 }]; + const { adapter, bridge } = bindHls(hls); + + expect(adapter.capabilities().qualityLevels).toBe(false); + bridge.destroy(); + }); + + it('switches through nextLevel and marks the manual selection', () => { + const hls = new FakeHls(); + hls.levels = [{ height: 1080 }, { height: 720 }]; + const { adapter, bridge } = bindHls(hls); + hls.assignments.length = 0; + + adapter.commands.setQualityLevel(1); + expect(hls.assignments).toEqual(['nextLevel:1']); + expect(adapter.state().qualityAutoEnabled).toBe(false); + expect(adapter.state().qualityLevels[1].selected).toBe(true); + expect(adapter.state().qualityLevels[0].selected).toBe(false); + bridge.destroy(); + }); + + it('returns to auto with the sentinel id', () => { + const hls = new FakeHls(); + hls.levels = [{ height: 1080 }, { height: 720 }]; + const { adapter, bridge } = bindHls(hls); + adapter.commands.setQualityLevel(0); + hls.assignments.length = 0; + + adapter.commands.setQualityLevel(-1); + expect(hls.assignments).toEqual(['nextLevel:-1']); + expect(adapter.state().qualityAutoEnabled).toBe(true); + expect( + adapter.state().qualityLevels.some((level) => level.selected) + ).toBe(false); + bridge.destroy(); + }); + + it('ignores invalid and out-of-range level ids', () => { + const hls = new FakeHls(); + hls.levels = [{ height: 1080 }, { height: 720 }]; + const { adapter, bridge } = bindHls(hls); + hls.assignments.length = 0; + + adapter.commands.setQualityLevel(-2); + adapter.commands.setQualityLevel(0.5); + adapter.commands.setQualityLevel(2); + adapter.commands.setQualityLevel(NaN); + + expect(hls.assignments).toEqual([]); + expect(adapter.state().qualityAutoEnabled).toBe(true); + bridge.destroy(); + }); +}); + describe('HtmlVideoPlayerControlsBridge HLS listener lifecycle', () => { const refreshEvents = [ Hls.Events.AUDIO_TRACKS_UPDATED, @@ -130,6 +204,9 @@ describe('HtmlVideoPlayerControlsBridge HLS listener lifecycle', () => { Hls.Events.SUBTITLE_TRACKS_CLEARED, Hls.Events.SUBTITLE_TRACK_SWITCH, Hls.Events.MANIFEST_LOADING, + Hls.Events.MANIFEST_PARSED, + Hls.Events.LEVELS_UPDATED, + Hls.Events.LEVEL_SWITCHED, ]; it('refreshes from every relevant HLS event with one callback reference', () => { diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.spec-fixtures.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.spec-fixtures.ts index 0de7e84a9..e161c9b46 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.spec-fixtures.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.spec-fixtures.ts @@ -17,11 +17,18 @@ interface FakeHlsTrack { lang?: string; } +interface FakeHlsLevel { + height?: number; + width?: number; + bitrate?: number; +} + type FakeHlsListener = (...args: unknown[]) => void; export class FakeHls { audioTracks: FakeHlsTrack[] = []; subtitleTracks: FakeHlsTrack[] = []; + levels: FakeHlsLevel[] = []; readonly assignments: string[] = []; subtitleTrackSwitchEvents = 0; private readonly listeners = new Map>(); @@ -29,6 +36,7 @@ export class FakeHls { private selectedAudioTrack = -1; private selectedSubtitleTrack = -1; private displaySubtitles = false; + private manualQualityLevel = -1; readonly on = jest.fn((event: string, listener: FakeHlsListener): void => { const eventListeners = @@ -66,6 +74,24 @@ export class FakeHls { } } + // Mirrors hls.js: assigning nextLevel records the manual level; -1 = auto. + get nextLevel(): number { + return this.manualQualityLevel; + } + + set nextLevel(value: number) { + this.assignments.push(`nextLevel:${value}`); + this.manualQualityLevel = value; + } + + get manualLevel(): number { + return this.manualQualityLevel; + } + + get autoLevelEnabled(): boolean { + return this.manualQualityLevel === -1; + } + get subtitleDisplay(): boolean { return this.displaySubtitles; } diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts index a0ad2352f..ab475a296 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts @@ -303,7 +303,7 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => { lifecycle .slice(0, destroyIndex) .filter((event) => event.startsWith('off:')) - ).toHaveLength(7); + ).toHaveLength(10); const internals = readHtmlPlayerInternals(component); expect(setSource).toHaveBeenCalledTimes(1); expect(setSource).toHaveBeenCalledWith({ kind: 'native' }); @@ -312,7 +312,7 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => { expect(internals.mpegtsPlayer).toBeNull(); expect(hlsInstances).toHaveLength(1); expect(mpegTsInstances).toHaveLength(0); - expect(hls.off).toHaveBeenCalledTimes(7); + expect(hls.off).toHaveBeenCalledTimes(10); expect(adapter.state().audioTracks).toEqual([]); expect(adapter.state().subtitleTracks).toEqual([]); }); diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts index 791b81a87..fa90cb99e 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts @@ -18,6 +18,7 @@ describe('ControlsMenuSelection', () => { setVolume: jest.fn(), setAudioTrack: jest.fn(), setSubtitleTrack: jest.fn(), + setQualityLevel: jest.fn(), setPlaybackSpeed: jest.fn(), setAspectRatio: jest.fn(), toggleRecording: jest.fn(), @@ -62,6 +63,15 @@ describe('ControlsMenuSelection', () => { expect(visibility.scheduleHide).toHaveBeenCalled(); }); + it('selects a quality level and closes the quality menu', () => { + menus.open('quality'); + selection.qualityLevel(-1); + + expect(commands.setQualityLevel).toHaveBeenCalledWith(-1); + expect(menus.qualityOpen()).toBe(false); + expect(visibility.scheduleHide).toHaveBeenCalled(); + }); + it('applies a playback speed and closes the speed menu', () => { menus.open('speed'); selection.speed(1.5); diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts index b676207ff..7e2938d3c 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts @@ -2,7 +2,7 @@ import type { ControlsMenuState } from './controls-menu-state'; import type { ControlsVisibility } from './controls-visibility'; import type { PlayerControlsCommands } from './player-controls.model'; -type MenuKey = 'audio' | 'subtitle' | 'speed' | 'aspect'; +type MenuKey = 'audio' | 'subtitle' | 'quality' | 'speed' | 'aspect'; export interface MenuSelectionDeps { commands: () => PlayerControlsCommands; @@ -32,6 +32,10 @@ export class ControlsMenuSelection { this.apply('subtitle', (c) => c.setSubtitleTrack(trackId)); } + qualityLevel(levelId: number): void { + this.apply('quality', (c) => c.setQualityLevel(levelId)); + } + speed(value: number): void { this.apply('speed', (c) => c.setPlaybackSpeed(value)); } diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts index dee25dfa2..59de54bd3 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts @@ -28,7 +28,14 @@ describe('ControlsMenuState', () => { expect(menus.anyOpen()).toBe(false); }); - it.each(['volume', 'audio', 'subtitle', 'speed', 'aspect'] as const)( + it.each([ + 'volume', + 'audio', + 'subtitle', + 'quality', + 'speed', + 'aspect', + ] as const)( 'closes an open %s menu when it becomes unavailable', (menu) => { const menus = new ControlsMenuState(); @@ -38,6 +45,7 @@ describe('ControlsMenuState', () => { volume: menu !== 'volume', audio: menu !== 'audio', subtitle: menu !== 'subtitle', + quality: menu !== 'quality', speed: menu !== 'speed', aspect: menu !== 'aspect', }); @@ -56,6 +64,7 @@ describe('ControlsMenuState', () => { volume: true, audio: true, subtitle: true, + quality: true, speed: true, aspect: true, }) @@ -73,6 +82,7 @@ describe('ControlsMenuState', () => { volume: false, audio: false, subtitle: false, + quality: false, speed: false, aspect: false, }) @@ -84,6 +94,7 @@ describe('ControlsMenuState', () => { ['volume', { volume: false }, {}], ['audio', {}, { audioTracks: [] }], ['subtitle', {}, { subtitleTracks: [] }], + ['quality', {}, { qualityLevels: [] }], ['speed', { playbackSpeed: false }, {}], ['aspect', { aspectRatio: false }, {}], ] as const)( @@ -99,6 +110,7 @@ describe('ControlsMenuState', () => { volume: true, audioTracks: true, subtitles: true, + qualityLevels: true, playbackSpeed: true, aspectRatio: true, ...capabilityOverrides, @@ -112,6 +124,10 @@ describe('ControlsMenuState', () => { subtitleTracks: [ { id: 1, label: 'English', selected: true }, ], + qualityLevels: [ + { id: 0, label: '1080p', selected: false }, + { id: 1, label: '720p', selected: false }, + ], ...stateOverrides, } ); diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts index 835ae1b15..98a0fd765 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts @@ -8,6 +8,7 @@ const CONTROL_MENUS = [ 'volume', 'audio', 'subtitle', + 'quality', 'speed', 'aspect', ] as const; @@ -30,6 +31,10 @@ function getControlsMenuAvailability( showControls && capabilities.subtitles && state.subtitleTracks.length > 0, + quality: + showControls && + capabilities.qualityLevels && + state.qualityLevels.length > 1, speed: showControls && capabilities.playbackSpeed, aspect: showControls && capabilities.aspectRatio, }; @@ -43,6 +48,7 @@ export class ControlsMenuState { readonly volumeOpen = signal(false); readonly audioOpen = signal(false); readonly subtitleOpen = signal(false); + readonly qualityOpen = signal(false); readonly speedOpen = signal(false); readonly aspectOpen = signal(false); @@ -51,6 +57,7 @@ export class ControlsMenuState { this.volumeOpen() || this.audioOpen() || this.subtitleOpen() || + this.qualityOpen() || this.speedOpen() || this.aspectOpen() ); @@ -78,6 +85,7 @@ export class ControlsMenuState { this.volumeOpen.set(false); this.audioOpen.set(false); this.subtitleOpen.set(false); + this.qualityOpen.set(false); this.speedOpen.set(false); this.aspectOpen.set(false); } @@ -112,6 +120,8 @@ export class ControlsMenuState { return this.audioOpen; case 'subtitle': return this.subtitleOpen; + case 'quality': + return this.qualityOpen; case 'speed': return this.speedOpen; case 'aspect': diff --git a/libs/ui/playback/src/lib/player-controls/controls-view-model.ts b/libs/ui/playback/src/lib/player-controls/controls-view-model.ts index c4dcf3ff5..8ff1525c6 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-view-model.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-view-model.ts @@ -53,6 +53,9 @@ export function createControlsViewModel(deps: ControlsViewModelDeps) { const hasSubtitleTracks = computed( () => capabilities().subtitles && state().subtitleTracks.length > 0 ); + const hasQualityLevels = computed( + () => capabilities().qualityLevels && state().qualityLevels.length > 1 + ); const canRecord = computed( () => capabilities().recording && @@ -103,6 +106,7 @@ export function createControlsViewModel(deps: ControlsViewModelDeps) { canTogglePlay, hasAudioTracks, hasSubtitleTracks, + hasQualityLevels, canRecord, isRecording, recordingStatusText, diff --git a/libs/ui/playback/src/lib/player-controls/player-controls-defaults.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls-defaults.spec.ts index 53aaf4381..126f04b86 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls-defaults.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls-defaults.spec.ts @@ -11,4 +11,12 @@ describe('player-controls defaults', () => { canPictureInPicture: false, }); }); + + it('defaults quality selection to unsupported with auto enabled', () => { + expect(DEFAULT_PLAYER_CAPABILITIES.qualityLevels).toBe(false); + expect(createEmptyControlsState()).toMatchObject({ + qualityLevels: [], + qualityAutoEnabled: true, + }); + }); }); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts b/libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts index ed003689a..d32c7871a 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts @@ -9,6 +9,7 @@ export const DEFAULT_PLAYER_CAPABILITIES: PlayerControlsCapabilities = { volume: false, audioTracks: false, subtitles: false, + qualityLevels: false, playbackSpeed: false, aspectRatio: false, recording: false, @@ -47,6 +48,8 @@ export function createEmptyControlsState(): PlayerControlsState { audioTracks: [], subtitleTracks: [], subtitlesEnabled: false, + qualityLevels: [], + qualityAutoEnabled: true, playbackSpeed: 1, speedPresets: DEFAULT_SPEED_PRESETS, aspectRatio: 'no', diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts index c71123976..587908a71 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts @@ -30,6 +30,7 @@ function createFakeController(): FakeController { setVolume: jest.fn(), setAudioTrack: jest.fn(), setSubtitleTrack: jest.fn(), + setQualityLevel: jest.fn(), setPlaybackSpeed: jest.fn(), setAspectRatio: jest.fn(), toggleRecording: jest.fn(), @@ -240,6 +241,63 @@ describe('PlayerControlsComponent capability contract', () => { expect(component.anyMenuOpen()).toBe(false); }); + it('renders the quality menu with Auto and routes level selection', () => { + const fake = createFakeController(); + const fixture = createControls(fake); + const component = fixture.componentInstance; + + fake.capabilities.set({ + ...DEFAULT_PLAYER_CAPABILITIES, + qualityLevels: true, + }); + fake.state.set({ + ...createEmptyControlsState(), + qualityLevels: [ + { id: 0, label: '1080p', selected: false }, + { id: 1, label: '720p', selected: false }, + ], + qualityAutoEnabled: true, + }); + fixture.detectChanges(); + + const root = fixture.nativeElement as HTMLElement; + const trigger = root.querySelector( + '[data-test-id="player-controls-quality"]' + ); + expect(trigger).not.toBeNull(); + trigger?.click(); + fixture.detectChanges(); + expect(component.menus.qualityOpen()).toBe(true); + + const entries = Array.from( + root.querySelectorAll( + '.player-controls__track-popover .player-controls__track' + ) + ); + expect(entries.map((entry) => entry.textContent?.trim())).toEqual([ + 'EMBEDDED_MPV.PLAYER.QUALITY_AUTOcheck', + '1080p', + '720p', + ]); + expect(entries[0].getAttribute('aria-checked')).toBe('true'); + + entries[2].click(); + fixture.detectChanges(); + expect(fake.commands.setQualityLevel).toHaveBeenCalledWith(1); + expect(component.menus.qualityOpen()).toBe(false); + + component.toggleMenu('quality'); + fake.state.set({ + ...createEmptyControlsState(), + qualityLevels: [{ id: 0, label: '1080p', selected: false }], + }); + fixture.detectChanges(); + expect(component.anyMenuOpen()).toBe(false); + expect( + root.querySelector('[data-test-id="player-controls-quality"]') + ).toBeNull(); + }); + it('hides the scrub slider without hiding live and recording status', () => { const fake = createFakeController(); const fixture = createControls(fake); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.html b/libs/ui/playback/src/lib/player-controls/player-controls.component.html index 585db0b26..3d356f1ad 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.html @@ -410,6 +410,83 @@ } + @if (hasQualityLevels()) { +
+ + @if (menus.qualityOpen()) { + + } +
+ } + @if (capabilities().playbackSpeed) {
diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index eb75601d8..ddb7808ec 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -42,6 +42,8 @@ import { Channel, EpgProgram, ExternalPlayerSession, + RecordingStartMetadata, + RecordingStoppedEvent, ResolvedPortalPlayback, Settings, VideoPlayer, @@ -122,7 +124,9 @@ class StubWebPlayerViewComponent { readonly playback = input(null); readonly playerOverride = input(null); readonly volume = input(1); + readonly recordingMetadata = input(null); readonly externalFallbackRequested = output(); + readonly recordingStopped = output(); } // Matches both live-panel selectors so the host's timeline ↔ list swap can be diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 04ccb2aa3..d0c97367d 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -103,6 +103,7 @@ import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components'; import { DataService, PlaylistsService, + RecordingsService, RuntimeCapabilitiesService, SettingsStore, TmdbEnrichmentService, @@ -112,11 +113,16 @@ import { createDevLogger, EpgProgram, ExternalPlayerSession, + filterRecordingProgramsOverlap, OPEN_MPV_PLAYER, OPEN_VLC_PLAYER, PLAYLIST_PARSE_BY_URL, M3uRecentlyViewedItem, + playlistDisplayLabel, PlaylistMeta, + RecordingStartMetadata, + RecordingStoppedEvent, + toRecordingProgramSnapshot, ResolvedPortalPlayback, STORE_KEY, Settings, @@ -411,6 +417,79 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { ); private readonly activePlaylistMeta = this.store.selectSignal(selectActivePlaylist); + private readonly recordingsService = inject(RecordingsService); + /** + * Channel/EPG snapshot for the embedded-MPV recording tracker, captured + * at recording start (EPG cannot be reconstructed after the fact). + */ + readonly recordingMetadata = computed(() => { + const channel = this.activeChannel(); + if (!channel) { + return null; + } + // Derive the airing program from the ACTIVE channel's own schedule + // and the 30 s clock — the NgRx `currentEpgProgram` retains its last + // value across a channel switch and through EPG gaps (the mirror + // effect below only dispatches when a program exists), so it can + // still describe the previous channel when recording starts. An EPG + // gap must snapshot no program, not a stale one — stop enrichment + // deliberately never overwrites a persisted start title. + const program = + findCurrentEpgProgram(this.epgPrograms(), this.epgNowMs()) ?? null; + const playlistName = playlistDisplayLabel( + this.activePlaylistMeta()?.title + ); + return { + channelName: + channel.name?.trim() || channel.tvg?.name?.trim() || 'Live TV', + channelLogoUrl: this.timelineChannelLogo() || undefined, + playlistId: this.activePlaylistId() || undefined, + playlistName: playlistName || undefined, + sourceType: 'm3u', + epgChannelId: resolveChannelEpgLookupKey(channel) || undefined, + // The EPG lookup key can be shared by several channels (same + // tvgId, or the name fallback); the channel id is unique. + sourceItemKey: channel.id, + currentProgram: program + ? toRecordingProgramSnapshot(program) + : undefined, + }; + }); + + /** + * Stop enrichment: report every program overlapping the recorded window + * from the in-memory multi-day schedule (a recording can span a program + * boundary). Fire-and-forget — a failed update leaves the start snapshot. + */ + onRecordingStopped(event: RecordingStoppedEvent): void { + // A channel switch auto-stops the recording, and by now this host + // already describes the new channel — enriching then would attach the + // wrong schedule (and could promote an unrelated program to the + // recording's title). + if ( + event.epgChannelId && + event.epgChannelId !== this.recordingMetadata()?.epgChannelId + ) { + return; + } + // The EPG key alone cannot tell two same-keyed channels apart — + // the channel id must also match the exact recorded selection. + if ( + event.sourceItemKey && + event.sourceItemKey !== this.recordingMetadata()?.sourceItemKey + ) { + return; + } + const programs = filterRecordingProgramsOverlap( + this.epgPrograms().map(toRecordingProgramSnapshot), + event.startedAt, + event.endedAt + ); + if (programs.length === 0) { + return; + } + void this.recordingsService.updatePrograms(event.targetPath, programs); + } /** * Without a single configured XMLTV source the whole playlist has no EPG, * so the panel's empty state should point at the EPG settings page diff --git a/libs/portal/downloads/feature/src/index.ts b/libs/portal/downloads/feature/src/index.ts index f0a82109d..aa6741e07 100644 --- a/libs/portal/downloads/feature/src/index.ts +++ b/libs/portal/downloads/feature/src/index.ts @@ -1,2 +1,3 @@ export * from './lib/downloads.component'; export * from './lib/offline-detail/download-offline-detail.component'; +export * from './lib/recording-detail/recording-detail.component'; diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts index f324798c7..bd8c70a55 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts @@ -74,7 +74,7 @@ function libraryItemIds(entities: readonly DownloadLibraryEntity[]): number[] { } describe('normalizeDownloadFilter', () => { - it.each(['all', 'movie', 'series', 'in-progress'] as const)( + it.each(['all', 'movie', 'series', 'in-progress', 'recording'] as const)( 'keeps the known %s filter', (filter) => { expect(normalizeDownloadFilter(filter)).toBe(filter); @@ -159,6 +159,20 @@ describe('buildDownloadManagerViewModel', () => { expect(inProgress.library).toEqual([]); }); + it('hides every download row under the recording filter but keeps counts', () => { + const downloads = [ + download(1, { status: 'queued' }), + download(2, { status: 'failed' }), + download(3, { status: 'completed' }), + ]; + const model = build(downloads, { filter: 'recording' }); + expect(model.active).toEqual([]); + expect(model.attention).toEqual([]); + expect(model.library).toEqual([]); + // Chip counts stay derived from the unfiltered scoped set. + expect(model.counts.all).toBe(3); + }); + it('keeps queued, downloading, and paused rows in the in-progress filter only', () => { const result = build( [ diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts index 3c14397ec..8309b6f83 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts @@ -17,7 +17,12 @@ export type { DownloadSeriesCardViewModel, } from './download-library.viewmodel'; -export type DownloadFilterId = 'all' | 'movie' | 'series' | 'in-progress'; +export type DownloadFilterId = + | 'all' + | 'movie' + | 'series' + | 'in-progress' + | 'recording'; export type DownloadAttentionReason = 'file-missing' | 'transfer'; @@ -59,6 +64,7 @@ export function normalizeDownloadFilter( case 'movie': case 'series': case 'in-progress': + case 'recording': return value; default: return 'all'; @@ -147,6 +153,11 @@ function matchesFilter( if (filter === 'series') { return row.item.contentType === 'episode'; } + // Recordings live in their own view model; the chip hides download rows + // symmetrically to how the download chips hide recordings. + if (filter === 'recording') { + return false; + } return true; } diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.html b/libs/portal/downloads/feature/src/lib/downloads.component.html index b9eff4737..6cc699e87 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.html +++ b/libs/portal/downloads/feature/src/lib/downloads.component.html @@ -5,14 +5,14 @@

{{ 'DOWNLOADS.TITLE' | translate }}

- @if (model().activeCount > 0) { + @if (activeCount() > 0) { {{ 'DOWNLOADS.ACTIVE_COUNT' - | translate: { count: model().activeCount } + | translate: { count: activeCount() } }} } @@ -227,6 +227,13 @@

{{ 'DOWNLOADS.NO_FILTER_MATCH_HINT' | translate }}

} @else { + + } diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts index 0df2fd033..3e3fe37a8 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts @@ -16,6 +16,8 @@ import { type DownloadItem, DownloadsService, PlaylistsService, + type RecordingItem, + RecordingsService, } from '@iptvnator/services'; import { PORTAL_SHELL_ACTIONS, @@ -27,6 +29,7 @@ import { BehaviorSubject, type Observable, Subject } from 'rxjs'; import { DownloadLibraryNavigationService } from './download-library-navigation.service'; import type { DownloadActionResult } from './download-actions'; import { DownloadManagerActionsService } from './download-manager-actions.service'; +import { RecordingManagerActionsService } from './recording-manager-actions.service'; import type { DownloadSeriesCardViewModel } from './download-manager.viewmodel'; import { DownloadsComponent } from './downloads.component'; @@ -67,6 +70,23 @@ function deferred(): Deferred { return { promise, resolve }; } +function recording( + id: number, + overrides: Partial = {} +): RecordingItem { + return { + id, + status: 'completed', + filePath: `/recordings/${id}.ts`, + channelName: `Channel ${id}`, + startedAt: '2026-08-15T21:00:00Z', + endedAt: '2026-08-15T21:58:00Z', + fileAvailability: 'available', + playlistId: 'playlist-a', + ...overrides, + }; +} + function download( id: number, overrides: Partial = {} @@ -141,11 +161,39 @@ describe('DownloadsComponent', () => { }; let snackBar: { open: jest.Mock }; let matDialog: { open: jest.Mock }; + let recordings: ReturnType>; + let recordingsService: { + readonly recordings: typeof recordings; + readonly isAvailable: ReturnType>; + readonly hasLoadedRecordings: ReturnType>; + readonly hasAuthoritativeRecordingList: ReturnType< + typeof signal + >; + readonly isLoadingRecordings: ReturnType>; + loadRecordings: jest.Mock; + stopRecording: jest.Mock; + removeRecording: jest.Mock; + playFile: jest.Mock; + revealFile: jest.Mock; + }; const success = async () => ({ success: true }); beforeEach(async () => { downloads = signal([]); + recordings = signal([]); + recordingsService = { + recordings, + isAvailable: signal(false), + hasLoadedRecordings: signal(true), + hasAuthoritativeRecordingList: signal(true), + isLoadingRecordings: signal(false), + loadRecordings: jest.fn(async () => undefined), + stopRecording: jest.fn(success), + removeRecording: jest.fn(success), + playFile: jest.fn(success), + revealFile: jest.fn(success), + }; routeParams = new BehaviorSubject(convertToParamMap({})); queryParams = new BehaviorSubject(convertToParamMap({})); activatedRoute = { @@ -244,6 +292,10 @@ describe('DownloadsComponent', () => { provide: DownloadsService, useValue: downloadsService, }, + { + provide: RecordingsService, + useValue: recordingsService, + }, { provide: PlaylistsService, useValue: { @@ -275,6 +327,7 @@ describe('DownloadsComponent', () => { }, { provide: MatDialog, useValue: matDialog }, DownloadManagerActionsService, + RecordingManagerActionsService, ], }, }) @@ -429,6 +482,32 @@ describe('DownloadsComponent', () => { expect(downloads()).toBe(globalItems); }); + it('counts recordings in the All chip and the active badge', () => { + // A manager listing only recordings must not read "All 0", and an + // active recording belongs in the header badge. + recordingsService.isAvailable.set(true); + recordings.set([ + recording(1), + recording(2, { status: 'recording', endedAt: undefined }), + ]); + fixture.detectChanges(); + + const all = component + .categories() + .find(({ category_id }) => category_id === 'all'); + const inProgress = component + .categories() + .find(({ category_id }) => category_id === 'in-progress'); + const recordingsChip = component + .categories() + .find(({ category_id }) => category_id === 'recording'); + + expect(all?.count).toBe(2); + expect(inProgress?.count).toBe(1); + expect(recordingsChip?.count).toBe(2); + expect(component.activeCount()).toBe(1); + }); + it('keeps retained offline downloads visible after the last source is removed', () => { playlistItems.next([]); downloads.set([ diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.ts b/libs/portal/downloads/feature/src/lib/downloads.component.ts index 6f44bb6fd..ea972f544 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.ts @@ -18,6 +18,8 @@ import { type DownloadItem, DownloadsService, PlaylistsService, + type RecordingItem, + RecordingsService, } from '@iptvnator/services'; import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui'; import { @@ -43,14 +45,26 @@ import { } from './download-manager.viewmodel'; import { DownloadQueueComponent } from './download-queue.component'; import { DownloadedSeriesDialogComponent } from './downloaded-series-dialog.component'; +import type { + RecordingActionResult, + RecordingItemAction, +} from './recording-actions'; +import { RecordingLibraryComponent } from './recording-library.component'; +import { RecordingManagerActionsService } from './recording-manager-actions.service'; +import { buildRecordingManagerViewModel } from './recording-manager.viewmodel'; +import { RecordingQueueComponent } from './recording-queue.component'; const FILTER_KEYS = { all: 'DOWNLOADS.FILTER.ALL', movie: 'DOWNLOADS.FILTER.MOVIES', series: 'DOWNLOADS.FILTER.SERIES', inProgress: 'DOWNLOADS.FILTER.IN_PROGRESS', + recording: 'DOWNLOADS.FILTER.RECORDINGS', } as const; +/** Cadence for refreshing the live file size of an active recording. */ +const ACTIVE_RECORDING_REFRESH_MS = 15_000; + @Component({ selector: 'app-downloads', templateUrl: './downloads.component.html', @@ -59,6 +73,7 @@ const FILTER_KEYS = { providers: [ DownloadLibraryNavigationService, DownloadManagerActionsService, + RecordingManagerActionsService, ], imports: [ DownloadLibraryComponent, @@ -68,6 +83,8 @@ const FILTER_KEYS = { MatDialogModule, MatIcon, MatTooltip, + RecordingLibraryComponent, + RecordingQueueComponent, TranslatePipe, ], }) @@ -83,6 +100,8 @@ export class DownloadsComponent { private readonly shellActions = inject(PORTAL_SHELL_ACTIONS); private readonly destroyRef = inject(DestroyRef); readonly downloadsService = inject(DownloadsService); + readonly recordingsService = inject(RecordingsService); + private readonly recordingActions = inject(RecordingManagerActionsService); readonly pendingIds = this.actions.pendingIds; readonly isClearing = this.actions.isClearing; @@ -139,20 +158,46 @@ export class DownloadsComponent { searchTerm: this.searchTerm(), }) ); + readonly recordingsAvailable = this.recordingsService.isAvailable; + readonly recordingPendingIds = this.recordingActions.pendingIds; + readonly recordingModel = computed(() => + buildRecordingManagerViewModel({ + recordings: this.recordingsAvailable() + ? this.recordingsService.recordings() + : [], + scopePlaylistId: this.playlistId() || undefined, + filter: this.selectedCategoryId(), + searchTerm: this.searchTerm(), + }) + ); + /** + * Header badge and All chip must count everything the page can list — + * otherwise a manager holding only recordings reads "All 0" and an active + * recording never reaches the active-count badge. + */ + readonly activeCount = computed( + () => this.model().activeCount + this.recordingModel().active.length + ); readonly categories = computed(() => this.buildCategories()); readonly collectionContext = createPortalCollectionContext({ ctx: this.collectionCtx, categories: this.categories, }); readonly hasScopedDownloads = computed( - () => this.model().scopedItems.length > 0 + () => + this.model().scopedItems.length > 0 || + this.recordingModel().count > 0 ); readonly hasVisibleDownloads = computed(() => { const model = this.model(); + const recordings = this.recordingModel(); return ( model.active.length + model.attention.length + - model.library.length > + model.library.length + + recordings.active.length + + recordings.attention.length + + recordings.library.length > 0 ); }); @@ -166,6 +211,21 @@ export class DownloadsComponent { constructor() { void this.downloadsService.loadDownloads(); + if (this.recordingsAvailable()) { + void this.recordingsService.loadRecordings(); + } + // Live file-size for an active recording: the backend only pings on + // transitions, so a modest poll keeps the growing size honest. + effect((onCleanup) => { + if (this.recordingModel().active.length === 0) { + return; + } + const intervalId = window.setInterval( + () => void this.recordingsService.loadRecordings(), + ACTIVE_RECORDING_REFRESH_MS + ); + onCleanup(() => window.clearInterval(intervalId)); + }); effect(() => { const routeFilter = this.routeFilter(); untracked(() => this.collectionContext.setCategoryId(routeFilter)); @@ -219,6 +279,24 @@ export class DownloadsComponent { return this.actions.run(action); } + async runRecordingAction( + action: RecordingItemAction + ): Promise { + return this.recordingActions.run(action); + } + + openRecordingDetail(item: RecordingItem): void { + if (this.recordingPendingIds().has(item.id)) { + return; + } + // Recordings have a single global detail route in v1 — portal-scoped + // managers navigate there too. + void this.router.navigate( + ['/workspace', 'downloads', 'recording', String(item.id)], + { state: { returnUrl: this.router.url } } + ); + } + clearFinished(): void { this.actions.clearFinished(this.playlistId() || undefined); } @@ -278,6 +356,9 @@ export class DownloadsComponent { private buildCategories(): XtreamCategory[] { this.languageChange(); const counts = this.model().counts; + const recordingCount = this.recordingsAvailable() + ? this.recordingModel().count + : 0; const categories = buildStandardCollectionCategories({ labels: { all: this.translate.instant(FILTER_KEYS.all), @@ -287,13 +368,28 @@ export class DownloadsComponent { }, counts, }); + const allCategory = categories.find( + ({ category_id }) => category_id === 'all' + ); + if (allCategory) { + allCategory.count = (allCategory.count ?? 0) + recordingCount; + } categories.push({ id: 4, category_id: 'in-progress', category_name: this.translate.instant(FILTER_KEYS.inProgress), - count: counts.inProgress, + count: counts.inProgress + this.recordingModel().active.length, parent_id: 0, }); + if (this.recordingsAvailable()) { + categories.push({ + id: 5, + category_id: 'recording', + category_name: this.translate.instant(FILTER_KEYS.recording), + count: recordingCount, + parent_id: 0, + }); + } return categories; } } diff --git a/libs/portal/downloads/feature/src/lib/recording-actions.ts b/libs/portal/downloads/feature/src/lib/recording-actions.ts new file mode 100644 index 000000000..60f45d33d --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-actions.ts @@ -0,0 +1,19 @@ +import type { RecordingItem } from '@iptvnator/services'; + +export type RecordingItemActionType = + | 'open-detail' + | 'play' + | 'remove' + | 'reveal' + | 'stop'; + +export type RecordingActionResult = + | 'success' + | 'file-missing' + | 'failed' + | 'ignored'; + +export interface RecordingItemAction { + readonly type: RecordingItemActionType; + readonly item: RecordingItem; +} diff --git a/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.html b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.html new file mode 100644 index 000000000..ff7eb6725 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.html @@ -0,0 +1,191 @@ +@if (item(); as recording) { + + @if (isMissing() || isFailed()) { +
+ + +
+ } @else { + + + + {{ 'DOWNLOADS.RECORDING_BADGE' | translate }} + + {{ recording.channelName }} + @if (durationLabel()) { + {{ durationLabel() }} + } + @if (sizeLabel()) { + {{ sizeLabel() }} + } + MPEG-TS + @if (recording.status === 'interrupted') { + + {{ 'DOWNLOADS.STATUS.INTERRUPTED' | translate }} + + } + + + +
+ + {{ recording.startedAt | date: 'longDate' }} + · {{ recording.startedAt | date: 'HH:mm' }} + @if (recording.endedAt) { + – {{ recording.endedAt | date: 'HH:mm' }} + } + +
+ @if (recording.playlistName; as playlistName) { +
+ + {{ + 'DOWNLOADS.RECORDING_DETAIL.RECORDED_FROM' + | translate: { playlist: playlistName } + }} + +
+ } +
+ + {{ recording.filePath }} + +
+
+ + + @if (isActive()) { + + } @else { + + } + + + + + + + } + + @if (coveredPrograms().length > 0) { +
+

+ {{ 'DOWNLOADS.RECORDING_DETAIL.PROGRAMS' | translate }} + +

+
    + @for (program of coveredPrograms(); track program.start) { +
  1. + +
    +
    + {{ program.title }} +
    + @if (program.description) { +
    + {{ program.description }} +
    + } +
    +
  2. + } +
+
+ } +
+} diff --git a/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.scss b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.scss new file mode 100644 index 000000000..e2a5dcccf --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.scss @@ -0,0 +1,94 @@ +:host { + display: block; + height: 100%; +} + +.recording-detail__badge { + display: inline-flex; + align-items: center; + gap: 4px; + background: color-mix(in srgb, var(--mat-sys-error) 18%, transparent); + color: var(--mat-sys-error); + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + + mat-icon { + width: 14px; + height: 14px; + font-size: 14px; + } +} + +.recording-detail__path { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.78rem; + opacity: 0.75; + word-break: break-all; +} + +.recording-detail__stop-action { + background: var(--mat-sys-error); + color: var(--mat-sys-on-error); +} + +.recording-detail__error-actions { + display: flex; + gap: 10px; + justify-content: center; +} + +.recording-detail__programs { + padding: 18px 0 8px; + + h3 { + display: inline-flex; + align-items: baseline; + gap: 8px; + margin: 0 0 12px; + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.01em; + } +} + +.recording-detail__program-list { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.recording-detail__program { + display: grid; + grid-template-columns: 108px minmax(0, 1fr); + gap: 14px; + align-items: baseline; + padding: 10px 12px; + border: 1px solid + color-mix( + in srgb, + var(--app-separator, var(--mat-sys-outline-variant)) 64%, + transparent + ); + border-radius: 10px; + background: var(--mat-sys-surface-container-low); + + time { + color: var(--mat-sys-on-surface-variant); + font-size: 0.82rem; + font-variant-numeric: tabular-nums; + } +} + +.recording-detail__program-title { + font-size: 0.9rem; + font-weight: 600; +} + +.recording-detail__program-desc { + margin-top: 2px; + color: var(--mat-sys-on-surface-variant); + font-size: 0.8rem; +} diff --git a/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.ts b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.ts new file mode 100644 index 000000000..fc98f503e --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.ts @@ -0,0 +1,153 @@ +import { DatePipe, Location } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslatePipe } from '@ngx-translate/core'; +import { RecordingsService } from '@iptvnator/services'; +import { + DetailActionsTemplateDirective, + DetailMetaTemplateDirective, + DetailTagsTemplateDirective, + PortalDetailShellComponent, +} from '@iptvnator/ui/components'; +import { map } from 'rxjs'; +import { formatDownloadBytes } from '../download-queue.component'; +import type { RecordingItemActionType } from '../recording-actions'; +import { RecordingManagerActionsService } from '../recording-manager-actions.service'; +import { + recordingDurationLabel, + recordingDurationSeconds, +} from '../recording-manager.viewmodel'; + +/** + * Focused detail for one live-TV recording + * (`/workspace/downloads/recording/:recordingId`). Rendered on the shared + * portal-detail-shell like the offline download details; the workspace shell + * hides the context panel and route search for this route. + */ +@Component({ + selector: 'app-recording-detail', + templateUrl: './recording-detail.component.html', + styleUrls: [ + '../../../../../../ui/components/src/lib/styles/detail-view.scss', + './recording-detail.component.scss', + ], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [RecordingManagerActionsService], + imports: [ + DatePipe, + DetailActionsTemplateDirective, + DetailMetaTemplateDirective, + DetailTagsTemplateDirective, + MatButtonModule, + MatIcon, + MatMenuModule, + PortalDetailShellComponent, + TranslatePipe, + ], +}) +export class RecordingDetailComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly location = inject(Location); + private readonly recordings = inject(RecordingsService); + private readonly actions = inject(RecordingManagerActionsService); + + private readonly returnUrl: string | null = + (this.router.getCurrentNavigation()?.extras.state?.[ + 'returnUrl' + ] as string) ?? null; + + readonly pendingIds = this.actions.pendingIds; + readonly recordingId = toSignal( + this.route.paramMap.pipe( + map((params) => Number(params.get('recordingId'))) + ), + { initialValue: Number(this.route.snapshot.params['recordingId']) } + ); + readonly item = computed( + () => + this.recordings + .recordings() + .find((row) => row.id === this.recordingId()) ?? null + ); + readonly isMissing = computed( + () => this.item()?.fileAvailability === 'missing' + ); + readonly isFailed = computed(() => this.item()?.status === 'failed'); + readonly isActive = computed(() => this.item()?.status === 'recording'); + readonly title = computed(() => { + const item = this.item(); + return item ? item.programTitle?.trim() || item.channelName : ''; + }); + readonly displayTitleNeedsTime = computed( + () => !this.item()?.programTitle?.trim() + ); + readonly durationSeconds = computed(() => { + const item = this.item(); + return item ? recordingDurationSeconds(item) : null; + }); + readonly durationLabel = computed(() => + recordingDurationLabel(this.durationSeconds()) + ); + readonly sizeLabel = computed(() => { + const bytes = this.item()?.fileSizeBytes; + return bytes ? formatDownloadBytes(bytes) : ''; + }); + /** ≥2 covered programs — the boundary-crossing case worth listing. */ + readonly coveredPrograms = computed(() => { + const programs = this.item()?.programs ?? []; + return programs.length >= 2 ? programs : []; + }); + + constructor() { + void this.recordings.loadRecordings(); + // Row gone after an authoritative load (removed here, removed + // elsewhere, or a bogus id) → return to the manager. + effect(() => { + if ( + this.recordings.hasLoadedRecordings() && + this.recordings.hasAuthoritativeRecordingList() && + !this.recordings.isLoadingRecordings() && + this.item() === null + ) { + this.goBack(); + } + }); + } + + goBack(): void { + // A validated returnUrl means the manager is the previous history + // entry: step back to it instead of pushing a third entry (which + // would make the browser Back button reopen this detail). Router + // navigation is the fallback for direct links with no usable + // history — matching the offline-detail navigation. + if (this.returnUrl) { + this.location.back(); + return; + } + void this.router.navigateByUrl('/workspace/downloads'); + } + + runAction(type: RecordingItemActionType): void { + const item = this.item(); + if (!item) { + return; + } + void this.actions.run({ type, item }); + } + + isPending(): boolean { + const item = this.item(); + return item !== null && this.pendingIds().has(item.id); + } +} diff --git a/libs/portal/downloads/feature/src/lib/recording-library.component.html b/libs/portal/downloads/feature/src/lib/recording-library.component.html new file mode 100644 index 000000000..dde04e70f --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-library.component.html @@ -0,0 +1,162 @@ +@if (items().length > 0) { +
+
+

+ {{ 'DOWNLOADS.RECORDINGS' | translate }} + +

+
+ +
+ @for (row of items(); track row.item.id) { +
+
+ + + @if (durationLabel(row)) { + + } @else if (row.interrupted) { + + } + + + + + + + + +
+ +
+ + + {{ row.channelName }} + · {{ row.item.startedAt | date: 'd MMM' }} + @if (row.item.fileSizeBytes) { + · {{ formatBytes(row.item.fileSizeBytes) }} + } + +
+
+ } +
+
+} diff --git a/libs/portal/downloads/feature/src/lib/recording-library.component.scss b/libs/portal/downloads/feature/src/lib/recording-library.component.scss new file mode 100644 index 000000000..cbf174edf --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-library.component.scss @@ -0,0 +1,210 @@ +@use '../../../../shared/ui/src/lib/styles/content-grid' as grid; + +:host { + display: block; +} + +.recording-library { + display: grid; + gap: 12px; + + &__header h2 { + display: inline-flex; + align-items: baseline; + gap: 8px; + margin: 0; + padding: 0 2px; + color: var(--app-heading-color); + font-size: 1.125rem; + font-weight: 600; + letter-spacing: -0.012em; + line-height: 1.2; + } + + // Wider tiles than the poster grid: recording artwork is a 16:9 channel + // tile, so the min width is ~1.55× the cover token. + &__grid { + @include grid.content-grid( + $min-width: min( + 100%, + calc(var(--cover-grid-min-width, 148px) * 1.55) + ), + $gap: var(--cover-gap, 16px) + ); + } + + &__card { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + + &:hover .recording-library__artwork, + &:focus-within .recording-library__artwork { + border-color: color-mix( + in srgb, + var(--mat-sys-primary) 55%, + var(--app-separator, var(--mat-sys-outline-variant)) + ); + transform: translateY(-2px); + } + } + + &__artwork { + position: relative; + overflow: hidden; + aspect-ratio: 16 / 9; + border: 1px solid + color-mix( + in srgb, + var(--app-separator, var(--mat-sys-outline-variant)) 78%, + transparent + ); + border-radius: 14px; + background: radial-gradient( + 130% 160% at 28% 18%, + color-mix( + in srgb, + var(--mat-sys-surface-container-highest) 82%, + var(--mat-sys-primary) + ) + 0%, + var(--mat-sys-surface-container-low) 68% + ); + transition: + transform 160ms ease, + border-color 160ms ease; + } + + &__artwork-button { + display: grid; + width: 100%; + height: 100%; + padding: 0; + border: 0; + background: none; + place-items: center; + cursor: pointer; + + img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + padding: 14px; + box-sizing: border-box; + } + } + + &__placeholder { + display: grid; + place-items: center; + color: var(--mat-sys-on-surface-variant); + + mat-icon { + width: 34px; + height: 34px; + font-size: 34px; + } + } + + &__type { + position: absolute; + top: 8px; + left: 8px; + padding: 3px 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--mat-sys-error) 28%, rgb(0 0 0 / 62%)); + color: var(--mat-sys-on-error, #fff); + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + backdrop-filter: blur(4px); + } + + &__duration { + position: absolute; + right: 8px; + bottom: 8px; + padding: 2px 8px; + border-radius: 6px; + background: rgb(0 0 0 / 62%); + color: #d6dde4; + font-size: 0.68rem; + font-variant-numeric: tabular-nums; + font-weight: 600; + backdrop-filter: blur(4px); + } + + &__more { + position: absolute; + top: 6px; + right: 6px; + width: 32px; + height: 32px; + color: #d6dde4; + + &::before { + content: ''; + position: absolute; + inset: 2px; + border-radius: 50%; + background: rgb(0 0 0 / 48%); + } + + mat-icon { + position: relative; + } + } + + &__body { + display: grid; + gap: 2px; + min-width: 0; + padding: 0 2px; + } + + &__title-button { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + min-width: 0; + padding: 0; + border: 0; + background: none; + color: var(--mat-sys-on-surface); + font: inherit; + font-size: 0.9rem; + font-weight: 600; + line-height: 1.3; + text-align: start; + cursor: pointer; + + &:hover:not(:disabled) { + text-decoration: underline; + } + } + + &__facts { + overflow: hidden; + color: var(--mat-sys-on-surface-variant); + font-size: 0.78rem; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +@media (prefers-reduced-motion: reduce) { + .recording-library { + &__artwork { + transition: none; + } + + &__card:hover .recording-library__artwork, + &__card:focus-within .recording-library__artwork { + transform: none; + } + } +} diff --git a/libs/portal/downloads/feature/src/lib/recording-library.component.spec.ts b/libs/portal/downloads/feature/src/lib/recording-library.component.spec.ts new file mode 100644 index 000000000..d48c8ab7f --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-library.component.spec.ts @@ -0,0 +1,273 @@ +import { OverlayContainer } from '@angular/cdk/overlay'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import type { RecordingItem } from '@iptvnator/services'; +import type { RecordingItemAction } from './recording-actions'; +import type { RecordingRowViewModel } from './recording-manager.viewmodel'; +import { RecordingLibraryComponent } from './recording-library.component'; + +const TEST_TRANSLATIONS = { + DOWNLOADS: { + RECORDINGS: 'Recordings', + RECORDING_BADGE: 'Recording', + PLAY: 'Play', + REVEAL: 'Show in folder', + OPEN_DETAILS: 'Open details', + REMOVE_FROM_MANAGER: 'Remove from manager', + MORE_ACTIONS: 'More actions', + STATUS: { + INTERRUPTED: 'Interrupted', + }, + ARIA: { + OPEN: 'Open {{title}}', + }, + }, + PORTALS: { + MULTI_SOURCE: { + SOURCE: 'Source', + }, + }, +}; + +function createItem( + id: number, + overrides: Partial = {} +): RecordingItem { + return { + id, + status: 'completed', + filePath: `/recordings/${id}.ts`, + channelName: `Channel ${id}`, + startedAt: '2026-08-15T12:00:00Z', + fileAvailability: 'available', + ...overrides, + }; +} + +function createRow( + item: RecordingItem, + overrides: Partial = {} +): RecordingRowViewModel { + return { + item, + programTitle: item.programTitle?.trim() ?? '', + channelName: item.channelName, + attentionReason: null, + durationSeconds: null, + interrupted: false, + ...overrides, + }; +} + +describe('RecordingLibraryComponent', () => { + let fixture: ComponentFixture; + let overlayContainer: OverlayContainer; + + function render( + items: readonly RecordingRowViewModel[], + pendingIds: ReadonlySet = new Set() + ): void { + fixture.componentRef.setInput('items', items); + fixture.componentRef.setInput('pendingIds', pendingIds); + fixture.detectChanges(); + } + + function card(id: number): HTMLElement { + const element = fixture.nativeElement.querySelector( + `[data-test-id="recording-card-${id}"]` + ); + expect(element).not.toBeNull(); + return element; + } + + async function openCardMenu(host: HTMLElement): Promise { + const more = host.querySelector( + '.recording-library__more' + ); + expect(more).not.toBeNull(); + more?.click(); + fixture.detectChanges(); + await fixture.whenStable(); + return overlayContainer.getContainerElement(); + } + + function menuButton(menu: ParentNode, action: string): HTMLButtonElement { + const button = menu.querySelector( + `[data-test-action="${action}"]` + ); + expect(button).not.toBeNull(); + return button as HTMLButtonElement; + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + RecordingLibraryComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + }).compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', TEST_TRANSLATIONS); + translate.use('en'); + overlayContainer = TestBed.inject(OverlayContainer); + fixture = TestBed.createComponent(RecordingLibraryComponent); + }); + + afterEach(() => { + overlayContainer.getContainerElement().replaceChildren(); + }); + + it('renders no section without items', () => { + render([]); + + expect( + fixture.nativeElement.querySelector( + '[data-test-id="recordings-library-section"]' + ) + ).toBeNull(); + expect(fixture.nativeElement.querySelector('section')).toBeNull(); + }); + + it('renders cards with badge, duration chip, interrupted marker, and facts', () => { + const finished = createRow( + createItem(1, { + programTitle: 'Evening news', + fileSizeBytes: 1.5 * 1024 ** 2, + }), + { durationSeconds: 3480 } + ); + const interrupted = createRow( + createItem(2, { status: 'interrupted' }), + { interrupted: true } + ); + render([finished, interrupted]); + + const section: HTMLElement = fixture.nativeElement.querySelector( + '[data-test-id="recordings-library-section"]' + ); + expect(section).not.toBeNull(); + expect(section.querySelector('h2 span')?.textContent?.trim()).toBe( + 'Recordings' + ); + expect( + section.querySelector('.app-count-badge')?.textContent?.trim() + ).toBe('2'); + + const finishedCard = card(1); + expect( + finishedCard + .querySelector('.recording-library__type') + ?.textContent?.trim() + ).toBe('Recording'); + const duration = finishedCard.querySelector( + '.recording-library__duration' + ); + expect(duration?.textContent).toContain('58 min'); + expect(duration?.textContent).not.toContain('Interrupted'); + const facts = finishedCard + .querySelector('.recording-library__facts') + ?.textContent?.replace(/\s+/g, ' ') + .trim(); + expect(facts).toContain('Channel 1'); + expect(facts).toContain('Aug'); + expect(facts).toContain('1.5 MB'); + expect( + finishedCard + .querySelector('.recording-library__title-button') + ?.textContent?.trim() + ).toBe('Evening news'); + + expect( + card(2) + .querySelector('.recording-library__duration') + ?.textContent?.trim() + ).toBe('Interrupted'); + }); + + it('opens the item from the artwork and title buttons', () => { + const item = createItem(3, { programTitle: 'Late movie' }); + const opened: RecordingItem[] = []; + fixture.componentInstance.openRequested.subscribe((openedItem) => + opened.push(openedItem) + ); + render([createRow(item)]); + + const host = card(3); + const artwork = host.querySelector( + '.recording-library__artwork-button' + ); + expect(artwork?.getAttribute('aria-label')).toBe('Open Late movie'); + artwork?.click(); + host.querySelector( + '.recording-library__title-button' + )?.click(); + + expect(opened).toEqual([item, item]); + expect(opened[0]).toBe(item); + expect(opened[1]).toBe(item); + }); + + it('routes play, reveal, remove, and details through the card menu', async () => { + const item = createItem(4, { + programTitle: 'Menu movie', + playlistName: 'Alpha Source', + }); + const emitted: RecordingItemAction[] = []; + const opened = jest.fn(); + fixture.componentInstance.itemAction.subscribe((action) => + emitted.push(action) + ); + fixture.componentInstance.openRequested.subscribe(opened); + render([createRow(item)]); + + const firstMenu = await openCardMenu(card(4)); + expect( + firstMenu + .querySelector('.download-source-menu-header') + ?.textContent?.replace(/\s+/g, ' ') + .trim() + ).toBe('Source Alpha Source'); + + const menuActions = [ + ['play-recording', 'play'], + ['reveal-recording', 'reveal'], + ['remove-recording', 'remove'], + ] as const; + let menu = firstMenu; + for (const [testAction] of menuActions) { + menuButton(menu, testAction).click(); + fixture.detectChanges(); + await fixture.whenStable(); + menu = await openCardMenu(card(4)); + } + + expect(emitted).toEqual( + menuActions.map(([, type]) => ({ type, item })) + ); + expect(opened).not.toHaveBeenCalled(); + + menuButton(menu, 'open-recording-detail').click(); + expect(opened).toHaveBeenCalledTimes(1); + expect(opened).toHaveBeenCalledWith(item); + }); + + it('falls back to channel name and start date for an untitled recording', () => { + const item = createItem(9); + render([createRow(item)]); + + const title = card(9) + .querySelector('.recording-library__title-button') + ?.textContent?.replace(/\s+/g, ' ') + .trim(); + expect(title?.startsWith('Channel 9 —')).toBe(true); + expect(title).toContain('Aug'); + expect( + card(9) + .querySelector('.recording-library__artwork-button') + ?.getAttribute('aria-label') + ).toBe('Open Channel 9'); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/recording-library.component.ts b/libs/portal/downloads/feature/src/lib/recording-library.component.ts new file mode 100644 index 000000000..1c2004e10 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-library.component.ts @@ -0,0 +1,73 @@ +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { TranslatePipe } from '@ngx-translate/core'; +import type { RecordingItem } from '@iptvnator/services'; +import { formatDownloadBytes } from './download-queue.component'; +import { DownloadSourceMenuHeaderComponent } from './download-source-menu-header.component'; +import type { + RecordingItemAction, + RecordingItemActionType, +} from './recording-actions'; +import { + recordingDurationLabel, + type RecordingRowViewModel, +} from './recording-manager.viewmodel'; + +/** + * "Recordings" library section: 16:9 channel-logo cards in the manager's + * rail card language. Recordings keep their own section below "Ready to + * watch" because their geometry (wide logo tile) cannot share a grid with + * 2:3 posters. + */ +@Component({ + selector: 'app-recording-library', + imports: [ + DatePipe, + MatButtonModule, + MatIcon, + MatMenuModule, + TranslatePipe, + DownloadSourceMenuHeaderComponent, + ], + templateUrl: './recording-library.component.html', + styleUrl: './recording-library.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RecordingLibraryComponent { + readonly items = input.required(); + readonly pendingIds = input>(new Set()); + readonly itemAction = output(); + readonly openRequested = output(); + + emitAction(type: RecordingItemActionType, item: RecordingItem): void { + if (!this.isPending(item.id)) { + this.itemAction.emit({ type, item }); + } + } + + requestOpen(item: RecordingItem): void { + if (!this.isPending(item.id)) { + this.openRequested.emit(item); + } + } + + isPending(id: number): boolean { + return this.pendingIds().has(id); + } + + durationLabel(row: RecordingRowViewModel): string { + return recordingDurationLabel(row.durationSeconds); + } + + formatBytes(bytes: number | undefined): string { + return formatDownloadBytes(bytes); + } +} diff --git a/libs/portal/downloads/feature/src/lib/recording-manager-actions.service.ts b/libs/portal/downloads/feature/src/lib/recording-manager-actions.service.ts new file mode 100644 index 000000000..ee5007f59 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-manager-actions.service.ts @@ -0,0 +1,154 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { type RecordingItem, RecordingsService } from '@iptvnator/services'; +import { DialogService } from '@iptvnator/ui/components'; +import type { + RecordingActionResult, + RecordingItemAction, +} from './recording-actions'; + +interface RecordingOperationResult { + readonly success: boolean; + readonly error?: string; +} + +/** Mirror of DownloadManagerActionsService for recording rows. */ +@Injectable() +export class RecordingManagerActionsService { + private readonly recordings = inject(RecordingsService); + private readonly dialogs = inject(DialogService); + private readonly translate = inject(TranslateService); + private readonly snackBar = inject(MatSnackBar); + + readonly pendingIds = signal>(new Set()); + + async run(action: RecordingItemAction): Promise { + const { item, type } = action; + if (this.pendingIds().has(item.id)) { + return 'ignored'; + } + switch (type) { + case 'remove': + this.confirmRemove(item); + return 'ignored'; + case 'stop': + return this.withPending(item.id, () => + this.recordings.stopRecording(item.id) + ); + case 'play': + case 'reveal': + return this.runFileAction(type, item); + case 'open-detail': + return 'ignored'; + } + } + + showActionError(error?: string): void { + const base = this.translate.instant('DOWNLOADS.ACTION_FAILED'); + this.snackBar.open(error ? `${base}: ${error}` : base, undefined, { + duration: 4000, + horizontalPosition: 'start', + }); + } + + private showMessage(key: string, duration: number): void { + this.snackBar.open(this.translate.instant(key), undefined, { + duration, + horizontalPosition: 'start', + }); + } + + private confirmRemove(item: RecordingItem): void { + this.dialogs.openConfirmDialog({ + title: this.translate.instant( + 'DOWNLOADS.REMOVE_RECORDING_DIALOG.TITLE' + ), + message: this.translate.instant( + 'DOWNLOADS.REMOVE_RECORDING_DIALOG.MESSAGE' + ), + confirmLabel: this.translate.instant( + 'DOWNLOADS.REMOVE_FROM_MANAGER' + ), + onConfirm: () => + this.withPending(item.id, () => + this.recordings.removeRecording(item.id) + ), + }); + } + + private async runFileAction( + type: 'play' | 'reveal', + item: RecordingItem + ): Promise { + const filePath = item.filePath; + if (!filePath) { + this.showMessage('DOWNLOADS.FILE_NOT_FOUND', 3000); + return 'file-missing'; + } + return this.withPending( + item.id, + () => + type === 'play' + ? this.recordings.playFile(filePath) + : this.recordings.revealFile(filePath), + async (error) => { + if (error === 'File not found') { + // Refresh so the row moves to Needs attention honestly. + await this.recordings.loadRecordings(); + } + this.showMessage( + error === 'File not found' + ? 'DOWNLOADS.FILE_NOT_FOUND' + : 'DOWNLOADS.FILE_ACTION_ERROR', + 3000 + ); + }, + (error) => (error === 'File not found' ? 'file-missing' : 'failed') + ); + } + + private async withPending( + itemId: number, + operation: () => Promise, + onFailure: (error?: string) => void | Promise = (error) => + this.showActionError(error), + failureResult: (error?: string) => RecordingActionResult = () => + 'failed' + ): Promise { + if (this.pendingIds().has(itemId)) { + return 'ignored'; + } + this.pendingIds.update((ids) => new Set(ids).add(itemId)); + try { + let failure: string | undefined; + let failed = false; + try { + const result = await operation(); + failed = !result.success; + failure = result.error; + } catch (error) { + failed = true; + failure = + error instanceof Error ? error.message : String(error); + } + if (failed) { + try { + await onFailure(failure); + } catch (error) { + this.showActionError( + error instanceof Error ? error.message : String(error) + ); + } + return failureResult(failure); + } + return 'success'; + } finally { + this.pendingIds.update((ids) => { + const next = new Set(ids); + next.delete(itemId); + return next; + }); + } + } +} diff --git a/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.spec.ts b/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.spec.ts new file mode 100644 index 000000000..eefa8c39f --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.spec.ts @@ -0,0 +1,179 @@ +import type { RecordingItem } from '@iptvnator/services'; +import { + buildRecordingManagerViewModel, + recordingDurationLabel, + recordingDurationSeconds, +} from './recording-manager.viewmodel'; + +let nextId = 1; + +function recording(overrides: Partial = {}): RecordingItem { + return { + id: nextId++, + status: 'completed', + filePath: `/rec/file-${nextId}.ts`, + channelName: 'Channel One', + startedAt: '2026-08-15T21:00:00Z', + endedAt: '2026-08-15T21:58:00Z', + fileAvailability: 'available', + ...overrides, + }; +} + +describe('recordingDurationSeconds', () => { + it('computes whole seconds from the recorded interval', () => { + expect(recordingDurationSeconds(recording())).toBe(58 * 60); + }); + + it('returns null while recording or for broken timestamps', () => { + expect( + recordingDurationSeconds(recording({ endedAt: undefined })) + ).toBeNull(); + expect( + recordingDurationSeconds(recording({ endedAt: 'garbage' })) + ).toBeNull(); + expect( + recordingDurationSeconds( + recording({ endedAt: '2026-08-15T20:00:00Z' }) + ) + ).toBeNull(); + }); +}); + +describe('buildRecordingManagerViewModel', () => { + beforeEach(() => { + nextId = 1; + }); + + it('partitions active, attention, and library rows', () => { + const model = buildRecordingManagerViewModel({ + recordings: [ + recording({ status: 'recording', endedAt: undefined }), + recording({ status: 'completed' }), + recording({ status: 'interrupted' }), + recording({ status: 'failed' }), + recording({ fileAvailability: 'missing' }), + ], + filter: 'all', + }); + expect(model.active).toHaveLength(1); + expect(model.library.map(({ item }) => item.status)).toEqual( + expect.arrayContaining(['completed', 'interrupted']) + ); + expect(model.library).toHaveLength(2); + expect( + model.attention.map(({ attentionReason }) => attentionReason) + ).toEqual(expect.arrayContaining(['failed', 'file-missing'])); + expect(model.count).toBe(5); + }); + + it('marks interrupted rows and keeps them playable', () => { + const model = buildRecordingManagerViewModel({ + recordings: [recording({ status: 'interrupted' })], + filter: 'recording', + }); + expect(model.library[0].interrupted).toBe(true); + }); + + it('keeps active recordings under the in-progress filter', () => { + // The chip counts them, so the page must list them. + const model = buildRecordingManagerViewModel({ + recordings: [ + recording({ status: 'recording', endedAt: undefined }), + recording({ status: 'completed' }), + recording({ status: 'failed' }), + ], + filter: 'in-progress', + }); + expect(model.active).toHaveLength(1); + expect(model.library).toEqual([]); + expect(model.attention).toEqual([]); + }); + + it('hides recordings under download-type filters but keeps the count', () => { + for (const filter of ['movie', 'series']) { + const model = buildRecordingManagerViewModel({ + recordings: [recording()], + filter, + }); + expect(model.active).toEqual([]); + expect(model.attention).toEqual([]); + expect(model.library).toEqual([]); + expect(model.count).toBe(1); + } + }); + + it('shows recordings under the all and recording filters', () => { + for (const filter of ['all', 'recording', undefined]) { + const model = buildRecordingManagerViewModel({ + recordings: [recording()], + filter, + }); + expect(model.library).toHaveLength(1); + } + }); + + it('scopes to the playlist, excluding rows without a playlist id', () => { + const model = buildRecordingManagerViewModel({ + recordings: [ + recording({ playlistId: 'playlist-a' }), + recording({ playlistId: 'playlist-b' }), + recording({ playlistId: undefined }), + ], + scopePlaylistId: 'playlist-a', + filter: 'all', + }); + expect(model.count).toBe(1); + expect(model.library).toHaveLength(1); + expect(model.library[0].item.playlistId).toBe('playlist-a'); + }); + + it('matches search against program, channel, playlist, and error text', () => { + const rows = [ + recording({ programTitle: 'Evening News' }), + recording({ channelName: 'Discovery' }), + recording({ playlistName: 'My provider' }), + recording({ status: 'failed', errorMessage: 'disk full' }), + ]; + const search = (term: string) => + buildRecordingManagerViewModel({ + recordings: rows, + filter: 'all', + searchTerm: term, + }); + expect(search('evening').library).toHaveLength(1); + expect(search('discovery').library).toHaveLength(1); + expect(search('provider').library).toHaveLength(1); + expect(search('disk').attention).toHaveLength(1); + expect(search('nothing-matches').library).toHaveLength(0); + }); + + it('sorts newest first by startedAt with id tiebreak', () => { + const model = buildRecordingManagerViewModel({ + recordings: [ + recording({ startedAt: '2026-08-13T10:00:00Z' }), + recording({ startedAt: '2026-08-15T10:00:00Z' }), + recording({ startedAt: '2026-08-14T10:00:00Z' }), + ], + filter: 'all', + }); + expect( + model.library.map(({ item }) => item.startedAt) + ).toEqual([ + '2026-08-15T10:00:00Z', + '2026-08-14T10:00:00Z', + '2026-08-13T10:00:00Z', + ]); + }); + + it('rounds duration minutes on the total before splitting hours', () => { + // 59:45 must read "1 h", never "60 min" — and 1:59:45 must read + // "2 h", never "1 h 60 min". + expect(recordingDurationLabel(59 * 60 + 45)).toBe('1 h'); + expect(recordingDurationLabel(3600 + 59 * 60 + 45)).toBe('2 h'); + expect(recordingDurationLabel(3600 + 5 * 60)).toBe('1 h 5 min'); + expect(recordingDurationLabel(45)).toBe('1 min'); + expect(recordingDurationLabel(0)).toBe(''); + expect(recordingDurationLabel(null)).toBe(''); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.ts b/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.ts new file mode 100644 index 000000000..4304f24d9 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.ts @@ -0,0 +1,169 @@ +import type { RecordingItem } from '@iptvnator/services'; +import { normalizeDownloadFilter } from './download-manager.viewmodel'; + +export type RecordingAttentionReason = 'file-missing' | 'failed'; + +export interface RecordingRowViewModel { + readonly item: RecordingItem; + /** Program title when EPG knew one; empty otherwise (template falls back + * to channel + start time, which needs the locale-aware date pipe). */ + readonly programTitle: string; + readonly channelName: string; + readonly attentionReason: RecordingAttentionReason | null; + /** endedAt − startedAt in whole seconds; null while recording/unknown. */ + readonly durationSeconds: number | null; + readonly interrupted: boolean; +} + +export interface RecordingManagerViewModel { + readonly active: readonly RecordingRowViewModel[]; + readonly attention: readonly RecordingRowViewModel[]; + readonly library: readonly RecordingRowViewModel[]; + /** Chip badge: every scoped recording regardless of the active filter. */ + readonly count: number; +} + +export interface BuildRecordingManagerViewModelInput { + readonly recordings: readonly RecordingItem[]; + readonly scopePlaylistId?: string; + readonly filter: string | null | undefined; + readonly searchTerm?: string; +} + +export function recordingDurationSeconds( + item: RecordingItem +): number | null { + if (!item.endedAt) { + return null; + } + const started = Date.parse(item.startedAt); + const ended = Date.parse(item.endedAt); + if ( + !Number.isFinite(started) || + !Number.isFinite(ended) || + ended <= started + ) { + return null; + } + return Math.floor((ended - started) / 1000); +} + +/** + * Shared "1 h 5 min" formatter for recording durations. Minutes are rounded + * on the TOTAL before splitting into hours, so a 59:45 recording reads + * "1 h", never "60 min" (or "1 h 60 min" an hour later). + */ +export function recordingDurationLabel( + totalSeconds: number | null +): string { + if (totalSeconds === null || totalSeconds <= 0) { + return ''; + } + const totalMinutes = Math.round(totalSeconds / 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours > 0) { + return minutes > 0 ? `${hours} h ${minutes} min` : `${hours} h`; + } + return `${Math.max(1, minutes)} min`; +} + +function isPlayableStatus(item: RecordingItem): boolean { + return item.status === 'completed' || item.status === 'interrupted'; +} + +function attentionReason( + item: RecordingItem +): RecordingAttentionReason | null { + if (item.status === 'failed') { + return 'failed'; + } + if (isPlayableStatus(item) && item.fileAvailability === 'missing') { + return 'file-missing'; + } + return null; +} + +function toRow(item: RecordingItem): RecordingRowViewModel { + return { + item, + programTitle: item.programTitle?.trim() ?? '', + channelName: item.channelName, + attentionReason: attentionReason(item), + durationSeconds: recordingDurationSeconds(item), + interrupted: item.status === 'interrupted', + }; +} + +function matchesSearch(row: RecordingRowViewModel, searchTerm: string): boolean { + const query = searchTerm.trim().toLowerCase(); + return ( + query.length === 0 || + [ + row.programTitle, + row.channelName, + row.item.playlistName ?? '', + row.item.errorMessage ?? '', + ].some((value) => value.toLowerCase().includes(query)) + ); +} + +function compareNewestFirst( + left: RecordingRowViewModel, + right: RecordingRowViewModel +): number { + return ( + Date.parse(right.item.startedAt) - Date.parse(left.item.startedAt) || + right.item.id - left.item.id + ); +} + +/** + * Sibling of buildDownloadManagerViewModel for live-TV recordings. + * + * The 'movie'/'series' chips hide recordings symmetrically to how the + * 'recording' chip hides downloads. 'in-progress' is about what is running + * right now, so it keeps the active recordings (and only those) — its chip + * counts them, and a chip whose count does not match its page is a lie. + */ +export function buildRecordingManagerViewModel({ + recordings, + scopePlaylistId, + filter, + searchTerm = '', +}: BuildRecordingManagerViewModelInput): RecordingManagerViewModel { + const scoped = recordings.filter( + (item) => + scopePlaylistId === undefined || item.playlistId === scopePlaylistId + ); + const activeFilter = normalizeDownloadFilter(filter); + const showsRecordings = + activeFilter === 'all' || + activeFilter === 'recording' || + activeFilter === 'in-progress'; + const visible = showsRecordings + ? scoped.map(toRow).filter((row) => matchesSearch(row, searchTerm)) + : []; + const activeOnly = activeFilter === 'in-progress'; + + return { + active: visible + .filter(({ item }) => item.status === 'recording') + .sort(compareNewestFirst), + attention: activeOnly + ? [] + : visible + .filter((row) => row.attentionReason !== null) + .sort(compareNewestFirst), + library: activeOnly + ? [] + : visible + .filter( + (row) => + row.attentionReason === null && + isPlayableStatus(row.item) + ) + .sort(compareNewestFirst), + count: scoped.length, + }; +} diff --git a/libs/portal/downloads/feature/src/lib/recording-queue.component.html b/libs/portal/downloads/feature/src/lib/recording-queue.component.html new file mode 100644 index 000000000..d5e613fc8 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-queue.component.html @@ -0,0 +1,216 @@ +@for (section of sections(); track section.testId) { +
+

+ + {{ section.headingKey | translate }} + + +

+ +
+ @for (row of section.items; track row.item.id) { +
+ + +
+
+ @if (row.item.status === 'recording') { + + + REC {{ elapsedLabel(row.item) }} + + } + +
+ + + + @if (row.item.status === 'recording') { + + } +
+ +
+ @if (row.item.status === 'recording') { + + } @else { + + } + + + + + @if (row.attentionReason === null) { + + } + @if (row.item.status !== 'recording') { + + } + +
+
+ } +
+
+} diff --git a/libs/portal/downloads/feature/src/lib/recording-queue.component.scss b/libs/portal/downloads/feature/src/lib/recording-queue.component.scss new file mode 100644 index 000000000..243344ebf --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-queue.component.scss @@ -0,0 +1,279 @@ +:host { + display: block; + container-type: inline-size; +} + +.recording-queue { + & + & { + margin-top: 20px; + } + + // Mirrors .download-queue__heading (dashboard rail title contract). + &__heading { + display: inline-flex; + align-items: baseline; + gap: 8px; + margin: 0 0 10px; + padding: 0 2px; + color: var(--app-heading-color, var(--mat-sys-on-surface)); + font-size: 1.125rem; + font-weight: 600; + letter-spacing: -0.012em; + line-height: 1.2; + } + + &__list { + display: grid; + overflow: hidden; + border: 1px solid + color-mix( + in srgb, + var(--app-separator, var(--mat-sys-outline-variant)) 78%, + transparent + ); + border-radius: 14px; + background: var(--mat-sys-surface-container-low); + } + + &__item { + display: grid; + grid-template-columns: 86px minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + min-width: 0; + padding: 12px 14px; + + & + & { + border-top: 1px solid + color-mix( + in srgb, + var(--app-separator, var(--mat-sys-outline-variant)) 64%, + transparent + ); + } + + &--pending { + cursor: progress; + + .recording-queue__artwork, + .recording-queue__actions { + opacity: 0.68; + } + } + + &--attention { + background: color-mix( + in srgb, + var(--mat-sys-tertiary-container) 18%, + var(--mat-sys-surface-container-low) + ); + + .recording-queue__artwork { + filter: saturate(0.42); + } + } + } + + // 16:9 channel-logo tile — recordings carry a channel logo, not a poster. + &__artwork { + position: relative; + width: 86px; + height: 52px; + overflow: hidden; + padding: 0; + border: 0; + border-radius: 9px; + background: var(--mat-sys-surface-container-highest); + color: var(--mat-sys-on-surface-variant); + cursor: pointer; + transition: + transform 140ms ease, + box-shadow 140ms ease; + + img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + padding: 6px; + box-sizing: border-box; + } + + &:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--mat-sys-level1); + } + } + + &__logo-placeholder { + display: grid; + width: 100%; + height: 100%; + place-items: center; + background: color-mix( + in srgb, + var(--mat-sys-primary-container) 44%, + var(--mat-sys-surface-container-highest) + ); + color: var(--mat-sys-on-primary-container); + } + + &__content { + display: grid; + gap: 4px; + min-width: 0; + } + + &__title-line { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + } + + &__title { + overflow: hidden; + min-width: 0; + padding: 0; + border: 0; + background: none; + color: var(--mat-sys-on-surface); + font: inherit; + font-weight: 600; + text-align: start; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + + &:hover:not(:disabled) { + text-decoration: underline; + } + } + + &__rec-chip { + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + padding: 2px 10px; + border-radius: 999px; + background: color-mix( + in srgb, + var(--mat-sys-error) 16%, + transparent + ); + color: var(--mat-sys-error); + font-size: 0.74rem; + font-variant-numeric: tabular-nums; + font-weight: 700; + letter-spacing: 0.04em; + } + + &__rec-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--mat-sys-error); + animation: recording-queue-pulse 1.4s ease-in-out infinite; + } + + &__warn-chip { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--mat-sys-on-tertiary-container); + font-size: 0.8rem; + font-weight: 600; + + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + } + } + + &__metadata { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 12px; + color: var(--mat-sys-on-surface-variant); + font-size: 0.82rem; + } + + &__bytes { + font-variant-numeric: tabular-nums; + } + + &__error { + overflow: hidden; + max-width: 100%; + color: var(--mat-sys-error); + text-overflow: ellipsis; + white-space: nowrap; + } + + // Indeterminate strip: the recording length is unknown by nature, so a + // percentage can never exist — a sliding pulse communicates "running". + &__progress { + position: relative; + overflow: hidden; + height: 3px; + margin-top: 4px; + border-radius: 2px; + background: color-mix(in srgb, var(--mat-sys-error) 18%, transparent); + + &::after { + content: ''; + position: absolute; + inset: 0; + width: 32%; + border-radius: 2px; + background: var(--mat-sys-error); + animation: recording-queue-slide 1.6s ease-in-out infinite; + } + } + + &__actions { + display: flex; + align-items: center; + gap: 6px; + } + + &__stop { + background: color-mix(in srgb, var(--mat-sys-error) 14%, transparent); + color: var(--mat-sys-error); + } +} + +@keyframes recording-queue-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + + 50% { + opacity: 0.35; + transform: scale(0.8); + } +} + +@keyframes recording-queue-slide { + 0% { + left: -32%; + } + + 100% { + left: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .recording-queue { + &__rec-dot, + &__progress::after { + animation: none; + } + } +} diff --git a/libs/portal/downloads/feature/src/lib/recording-queue.component.spec.ts b/libs/portal/downloads/feature/src/lib/recording-queue.component.spec.ts new file mode 100644 index 000000000..92e04f3fe --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-queue.component.spec.ts @@ -0,0 +1,308 @@ +import { OverlayContainer } from '@angular/cdk/overlay'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import type { RecordingItem } from '@iptvnator/services'; +import type { RecordingItemAction } from './recording-actions'; +import type { RecordingRowViewModel } from './recording-manager.viewmodel'; +import { RecordingQueueComponent } from './recording-queue.component'; + +const TEST_TRANSLATIONS = { + DOWNLOADS: { + RECORDING_NOW: 'Recording now', + NEEDS_ATTENTION: 'Needs attention', + STOP_RECORDING: 'Stop recording', + REMOVE_FROM_MANAGER: 'Remove from manager', + REVEAL: 'Show in folder', + MORE_ACTIONS: 'More actions', + STATUS: { + FILE_MISSING: 'File missing', + FAILED: 'Failed', + }, + ARIA: { + OPEN: 'Open {{title}}', + }, + }, + PORTALS: { + MULTI_SOURCE: { + SOURCE: 'Source', + }, + }, +}; + +function createItem( + id: number, + status: RecordingItem['status'], + overrides: Partial = {} +): RecordingItem { + return { + id, + status, + filePath: `/recordings/${id}.ts`, + channelName: `Channel ${id}`, + startedAt: '2026-08-15T12:00:00Z', + fileAvailability: 'available', + ...overrides, + }; +} + +function createRow( + item: RecordingItem, + overrides: Partial = {} +): RecordingRowViewModel { + return { + item, + programTitle: item.programTitle?.trim() ?? '', + channelName: item.channelName, + attentionReason: null, + durationSeconds: null, + interrupted: false, + ...overrides, + }; +} + +describe('RecordingQueueComponent', () => { + let fixture: ComponentFixture; + let overlayContainer: OverlayContainer; + + function render( + activeItems: readonly RecordingRowViewModel[] = [], + attentionItems: readonly RecordingRowViewModel[] = [], + pendingIds: ReadonlySet = new Set() + ): void { + fixture.componentRef.setInput('activeItems', activeItems); + fixture.componentRef.setInput('attentionItems', attentionItems); + fixture.componentRef.setInput('pendingIds', pendingIds); + fixture.detectChanges(); + } + + function row(id: number): HTMLElement { + const element = fixture.nativeElement.querySelector( + `[data-test-id="recording-queue-item-${id}"]` + ); + expect(element).not.toBeNull(); + return element; + } + + function actionButton(host: ParentNode, action: string): HTMLButtonElement { + const button = host.querySelector( + `[data-test-action="${action}"]` + ); + expect(button).not.toBeNull(); + return button as HTMLButtonElement; + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + RecordingQueueComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + }).compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', TEST_TRANSLATIONS); + translate.use('en'); + overlayContainer = TestBed.inject(OverlayContainer); + fixture = TestBed.createComponent(RecordingQueueComponent); + }); + + afterEach(() => { + overlayContainer.getContainerElement().replaceChildren(); + jest.useRealTimers(); + }); + + it('renders no sections for empty inputs', () => { + render([], []); + + expect(fixture.nativeElement.querySelector('section')).toBeNull(); + }); + + it('renders an active recording with REC elapsed chip and stop as the only primary action', () => { + jest.useFakeTimers().setSystemTime( + new Date('2026-08-15T12:01:30Z') + ); + const item = createItem(1, 'recording', { + programTitle: 'Evening news', + fileSizeBytes: 1.5 * 1024 ** 2, + }); + const emitted: RecordingItemAction[] = []; + fixture.componentInstance.itemAction.subscribe((action) => + emitted.push(action) + ); + render([createRow(item)], []); + + const section: HTMLElement = fixture.nativeElement.querySelector( + '[data-test-id="recordings-active-section"]' + ); + expect(section).not.toBeNull(); + expect( + section + .querySelector('.recording-queue__heading-label') + ?.textContent?.trim() + ).toBe('Recording now'); + expect( + section.querySelector('.app-count-badge')?.textContent?.trim() + ).toBe('1'); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="recordings-attention-section"]' + ) + ).toBeNull(); + + const host = row(1); + const recChip = host.querySelector( + '.recording-queue__rec-chip' + ); + expect(recChip?.textContent?.replace(/\s+/g, ' ').trim()).toBe( + 'REC 01:30' + ); + expect( + host.querySelector('.recording-queue__progress') + ).not.toBeNull(); + expect( + host.querySelector('.recording-queue__bytes')?.textContent + ).toContain('1.5 MB'); + expect( + host.querySelector('[data-test-action="remove-recording"]') + ).toBeNull(); + + jest.advanceTimersByTime(1000); + fixture.detectChanges(); + expect( + host + .querySelector('.recording-queue__rec-chip') + ?.textContent?.replace(/\s+/g, ' ') + .trim() + ).toBe('REC 01:31'); + + actionButton(host, 'stop-recording').click(); + expect(emitted).toEqual([{ type: 'stop', item }]); + }); + + it('renders a missing-file recording as attention with remove and no reveal', async () => { + const item = createItem(2, 'completed', { + programTitle: 'Vanished show', + fileAvailability: 'missing', + playlistName: 'Alpha Source', + }); + const viewModel = createRow(item, { attentionReason: 'file-missing' }); + const emitted: RecordingItemAction[] = []; + fixture.componentInstance.itemAction.subscribe((action) => + emitted.push(action) + ); + render([], [viewModel]); + + const section: HTMLElement = fixture.nativeElement.querySelector( + '[data-test-id="recordings-attention-section"]' + ); + expect(section).not.toBeNull(); + expect( + section + .querySelector('.recording-queue__heading-label') + ?.textContent?.trim() + ).toBe('Needs attention'); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="recordings-active-section"]' + ) + ).toBeNull(); + + const host = row(2); + const warnChip = host.querySelector( + '.recording-queue__warn-chip' + ); + expect(warnChip?.textContent).toContain('File missing'); + expect(warnChip?.querySelector('mat-icon')?.textContent?.trim()).toBe( + 'file_off' + ); + expect(host.querySelector('.recording-queue__progress')).toBeNull(); + expect( + host.querySelector('[data-test-action="stop-recording"]') + ).toBeNull(); + + actionButton(host, 'remove-recording').click(); + expect(emitted).toEqual([{ type: 'remove', item }]); + + actionButton(host, 'more').click(); + fixture.detectChanges(); + await fixture.whenStable(); + + const menu = overlayContainer.getContainerElement(); + expect( + menu.querySelector('[data-test-action="reveal-recording"]') + ).toBeNull(); + actionButton(menu, 'menu-remove-recording').click(); + expect(emitted).toEqual([ + { type: 'remove', item }, + { type: 'remove', item }, + ]); + }); + + it('shows the failed status chip for failed recordings', () => { + const item = createItem(3, 'failed', { + errorMessage: 'stream dropped', + }); + render([], [createRow(item, { attentionReason: 'failed' })]); + + const host = row(3); + const warnChip = host.querySelector( + '.recording-queue__warn-chip' + ); + expect(warnChip?.textContent).toContain('Failed'); + expect(warnChip?.querySelector('mat-icon')?.textContent?.trim()).toBe( + 'error' + ); + expect( + host.querySelector('.recording-queue__error')?.textContent?.trim() + ).toBe('stream dropped'); + }); + + it('falls back to channel name and start time for an untitled recording', () => { + const item = createItem(4, 'completed'); + const opened: RecordingItem[] = []; + fixture.componentInstance.openRequested.subscribe((openedItem) => + opened.push(openedItem) + ); + render([], [createRow(item, { attentionReason: 'failed' })]); + + const title = row(4).querySelector( + '[data-test-control="title"]' + ); + const text = title?.textContent?.replace(/\s+/g, ' ').trim() ?? ''; + expect(text.startsWith('Channel 4 —')).toBe(true); + expect(text).toContain('Aug'); + expect( + row(4) + .querySelector('[data-test-control="artwork"]') + ?.getAttribute('aria-label') + ).toBe('Open Channel 4'); + + title?.click(); + expect(opened).toEqual([item]); + expect(opened[0]).toBe(item); + }); + + it('disables every control and suppresses emissions while pending', () => { + const item = createItem(6, 'recording'); + const emitted = jest.fn(); + const opened = jest.fn(); + fixture.componentInstance.itemAction.subscribe(emitted); + fixture.componentInstance.openRequested.subscribe(opened); + render([createRow(item)], [], new Set([6])); + + const host = row(6); + expect(host.getAttribute('aria-busy')).toBe('true'); + const buttons = Array.from( + host.querySelectorAll('button') + ); + expect(buttons.length).toBeGreaterThan(0); + expect(buttons.every(({ disabled }) => disabled)).toBe(true); + + fixture.componentInstance.emitAction('stop', item); + fixture.componentInstance.requestOpen(item); + expect(emitted).not.toHaveBeenCalled(); + expect(opened).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/recording-queue.component.ts b/libs/portal/downloads/feature/src/lib/recording-queue.component.ts new file mode 100644 index 000000000..41a2ca5cb --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/recording-queue.component.ts @@ -0,0 +1,138 @@ +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; +import type { RecordingItem } from '@iptvnator/services'; +import { formatDownloadBytes } from './download-queue.component'; +import { DownloadSourceMenuHeaderComponent } from './download-source-menu-header.component'; +import type { + RecordingItemAction, + RecordingItemActionType, +} from './recording-actions'; +import type { RecordingRowViewModel } from './recording-manager.viewmodel'; + +interface RecordingQueueSection { + readonly headingId: string; + readonly headingKey: string; + readonly items: readonly RecordingRowViewModel[]; + readonly testId: string; +} + +/** + * Queue-area sections for live-TV recordings: "Recording now" (pulsing REC, + * elapsed time, live file size — never a percentage, the length is unknown) + * and a recordings-only "Needs attention" list (no re-download: a broadcast + * cannot be re-recorded, so Remove is the only affordance). + */ +@Component({ + selector: 'app-recording-queue', + imports: [ + DatePipe, + MatButtonModule, + MatIcon, + MatMenuModule, + MatTooltip, + TranslatePipe, + DownloadSourceMenuHeaderComponent, + ], + templateUrl: './recording-queue.component.html', + styleUrl: './recording-queue.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RecordingQueueComponent { + readonly activeItems = input.required(); + readonly attentionItems = + input.required(); + readonly pendingIds = input>(new Set()); + readonly itemAction = output(); + readonly openRequested = output(); + + private readonly elapsedTick = signal(Date.now()); + + readonly sections = computed(() => { + const sections: RecordingQueueSection[] = [ + { + headingId: 'recordings-active-heading', + headingKey: 'DOWNLOADS.RECORDING_NOW', + items: this.activeItems(), + testId: 'recordings-active-section', + }, + { + headingId: 'recordings-attention-heading', + headingKey: 'DOWNLOADS.NEEDS_ATTENTION', + items: this.attentionItems(), + testId: 'recordings-attention-section', + }, + ]; + return sections.filter(({ items }) => items.length > 0); + }); + + constructor() { + effect((onCleanup) => { + if (this.activeItems().length === 0) { + return; + } + untracked(() => this.elapsedTick.set(Date.now())); + const intervalId = window.setInterval( + () => this.elapsedTick.set(Date.now()), + 1000 + ); + onCleanup(() => window.clearInterval(intervalId)); + }); + } + + emitAction(type: RecordingItemActionType, item: RecordingItem): void { + if (!this.isPending(item.id)) { + this.itemAction.emit({ type, item }); + } + } + + requestOpen(item: RecordingItem): void { + if (!this.isPending(item.id)) { + this.openRequested.emit(item); + } + } + + isPending(id: number): boolean { + return this.pendingIds().has(id); + } + + /** Elapsed REC time from started_at against wall clock — no accumulator. */ + elapsedLabel(item: RecordingItem): string { + this.elapsedTick(); + const startedMs = Date.parse(item.startedAt); + if (!Number.isFinite(startedMs)) { + return '00:00'; + } + const total = Math.max(0, Math.floor((Date.now() - startedMs) / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + const mmss = `${String(minutes).padStart(2, '0')}:${String( + seconds + ).padStart(2, '0')}`; + return hours > 0 ? `${hours}:${mmss}` : mmss; + } + + attentionStatusKey(row: RecordingRowViewModel): string { + return row.attentionReason === 'file-missing' + ? 'DOWNLOADS.STATUS.FILE_MISSING' + : 'DOWNLOADS.STATUS.FAILED'; + } + + formatBytes(bytes: number | undefined): string { + return formatDownloadBytes(bytes); + } +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 94e3de217..8800ce7f8 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -64,9 +64,11 @@ [title]="activeDetail()?.playback?.title ?? ''" [playback]="inlinePlayback()" [playerOverride]="player()" + [recordingMetadata]="recordingMetadata()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) " + (recordingStopped)="onRecordingStopped($event)" /> } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index f43570e82..ccb0e3e7b 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -1,11 +1,4 @@ -import { - ChangeDetectionStrategy, - Component, - Directive, - input, - output, - signal, -} from '@angular/core'; +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; @@ -13,7 +6,6 @@ import { GlobalFavoritesListComponent } from '../global-favorites-list/global-fa import { UnifiedLiveTabComponent } from './unified-live-tab.component'; import { AudioPlayerComponent, - type PlaybackFallbackRequest, WebPlayerViewComponent, } from '@iptvnator/ui/playback'; import { @@ -29,107 +21,24 @@ import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { EpgItem, EpgProgram, - ResolvedPortalPlayback, VideoPlayer, } from '@iptvnator/shared/interfaces'; import { - DEFAULT_FAVORITES_CHANNEL_SORT_MODE, PORTAL_PLAYER, - FavoritesChannelSortMode, UnifiedCollectionItem, - UnifiedFavoriteChannel, } from '@iptvnator/portal/shared/util'; import { StreamResolverService, UnifiedRecentDataService, } from '@iptvnator/portal/shared/data-access'; import { createPlaybackSessionKey as sessionKey } from '@iptvnator/playback/util'; - -@Directive({ - selector: '[appResizable]', -}) -class StubResizableDirective { - readonly minWidth = input(); - readonly maxWidth = input(); - readonly defaultWidth = input(); - readonly storageKey = input(''); -} - -@Component({ - selector: 'app-global-favorites-list', - template: '', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -class StubGlobalFavoritesListComponent { - readonly channels = input.required(); - readonly mode = input<'favorites' | 'recent'>('favorites'); - readonly showEpg = input(true); - readonly favoriteUids = input>(new Set()); - readonly epgMap = input>(new Map()); - readonly progressTick = input(0); - readonly activeUid = input(null); - readonly searchTermInput = input(''); - readonly draggable = input(true); - readonly sortMode = input( - DEFAULT_FAVORITES_CHANNEL_SORT_MODE - ); - - readonly channelSelected = output(); - readonly channelsReordered = output(); - readonly favoriteToggled = output(); - readonly removeRequested = output(); -} - -// Matches both live-panel selectors so the host's timeline ↔ list swap can be -// asserted by tag name; both branches share the identical contract. -@Component({ - selector: 'app-epg-timeline, app-epg-list-view', - template: '
', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -class StubEpgTimelineComponent { - readonly programs = input([]); - readonly channelName = input(''); - readonly channelLogo = input(''); - readonly archivePlaybackAvailable = input(false); - readonly archiveDays = input(0); - readonly activeProgram = input(null); - readonly isLivePlayback = input(true); - readonly selectedDate = input(null); - readonly collapsed = input(false); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly summary = input(null); - readonly summaryLabelKey = input(''); - readonly selectedDateChange = output(); - readonly collapsedChange = output(); - readonly programActivated = output(); - readonly returnToLive = output(); -} - -@Component({ - selector: 'app-audio-player', - template: '
', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -class StubAudioPlayerComponent { - readonly icon = input(''); - readonly url = input.required(); - readonly channelName = input(''); -} - -@Component({ - selector: 'app-web-player-view', - template: '
', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -class StubWebPlayerViewComponent { - readonly playbackSessionKey = input.required(); - readonly streamUrl = input.required(); - readonly title = input(''); - readonly playback = input(null); - readonly playerOverride = input(null); - readonly externalFallbackRequested = output(); -} +import { + StubAudioPlayerComponent, + StubEpgTimelineComponent, + StubGlobalFavoritesListComponent, + StubResizableDirective, + StubWebPlayerViewComponent, +} from './unified-live-tab.spec-stubs'; describe('UnifiedLiveTabComponent', () => { let fixture: ComponentFixture; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 2c34a5ef1..b6a61f0a9 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -62,8 +62,21 @@ import { WebPlayerViewComponent, } from '@iptvnator/ui/playback'; import { ResizableDirective } from '@iptvnator/ui/components'; -import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; -import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { + RecordingsService, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; +import { + buildStalkerEpgMappingKey, + buildXtreamEpgMappingKey, + EpgProgram, + filterRecordingProgramsOverlap, + playlistDisplayLabel, + RecordingStartMetadata, + RecordingStoppedEvent, + toRecordingProgramSnapshot, +} from '@iptvnator/shared/interfaces'; import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key'; @Component({ @@ -274,6 +287,96 @@ export class UnifiedLiveTabComponent { readonly liveEpgPanelSummaryLabelKey = computed(() => this.activeTimeshift() ? 'EPG.ARCHIVE_PLAYBACK' : 'EPG.CURRENT_PROGRAM' ); + private readonly recordingsService = inject(RecordingsService); + /** Channel/EPG snapshot for the embedded-MPV recording tracker. */ + readonly recordingMetadata = computed(() => { + const item = this.activeItem(); + if (!item) { + return null; + } + // Track the 30 s tick: without it this computed caches its + // Date.now() verdict, and a recording started after an EPG boundary + // would snapshot the previous show. + this.progressTick(); + const now = Date.now(); + const program = + this.timelinePrograms().find((candidate) => { + const start = Date.parse(candidate.start); + const stop = Date.parse(candidate.stop); + return ( + Number.isFinite(start) && + Number.isFinite(stop) && + start <= now && + now < stop + ); + }) ?? null; + return { + channelName: item.name?.trim() || 'Live TV', + channelLogoUrl: + this.timelineChannelLogo() || item.logo || undefined, + playlistId: item.playlistId, + playlistName: playlistDisplayLabel(item.playlistName) || undefined, + sourceType: item.sourceType, + epgChannelId: this.recordingEpgChannelId(item), + // The EPG key is not unique for M3U items (shared tvgId, or the + // display-name fallback); the uid names the exact selection. + sourceItemKey: item.uid, + currentProgram: program + ? toRecordingProgramSnapshot(program) + : undefined, + }; + }); + + /** Stop enrichment: programs overlapping the recorded window. */ + onRecordingStopped(event: RecordingStoppedEvent): void { + // A channel switch auto-stops the recording, and by now this host + // already describes the new channel — enriching then would attach the + // wrong schedule (and could promote an unrelated program to the + // recording's title). + if ( + event.epgChannelId && + event.epgChannelId !== this.recordingMetadata()?.epgChannelId + ) { + return; + } + // The EPG key alone cannot tell two same-keyed M3U items apart — + // the uid must also match the exact recorded selection. + if ( + event.sourceItemKey && + event.sourceItemKey !== this.recordingMetadata()?.sourceItemKey + ) { + return; + } + const programs = filterRecordingProgramsOverlap( + this.timelinePrograms().map(toRecordingProgramSnapshot), + event.startedAt, + event.endedAt + ); + if (programs.length === 0) { + return; + } + void this.recordingsService.updatePrograms(event.targetPath, programs); + } + + private recordingEpgChannelId( + item: UnifiedCollectionItem + ): string | undefined { + switch (item.sourceType) { + case 'm3u': + return item.tvgId?.trim() || item.name?.trim() || undefined; + case 'xtream': + return item.xtreamId !== undefined + ? buildXtreamEpgMappingKey(item.playlistId, item.xtreamId) + : undefined; + case 'stalker': + return item.stalkerId !== undefined + ? buildStalkerEpgMappingKey( + item.playlistId, + String(item.stalkerId) + ) + : undefined; + } + } /** * The channel list in exactly the order the sidebar renders it diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts new file mode 100644 index 000000000..7765a60a8 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts @@ -0,0 +1,115 @@ +import { + ChangeDetectionStrategy, + Component, + Directive, + input, + output, +} from '@angular/core'; +import { type PlaybackFallbackRequest } from '@iptvnator/ui/playback'; +import { EpgProgramActivationEvent } from '@iptvnator/ui/epg'; +import { + EpgProgram, + RecordingStartMetadata, + RecordingStoppedEvent, + ResolvedPortalPlayback, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; +import { + DEFAULT_FAVORITES_CHANNEL_SORT_MODE, + FavoritesChannelSortMode, + UnifiedFavoriteChannel, +} from '@iptvnator/portal/shared/util'; + +/** + * Stand-ins for the components UnifiedLiveTabComponent renders. They mirror + * the real inputs/outputs so a spec can assert what the host hands each child + * without pulling in the players, EPG timeline or the Electron bridge. + */ + +@Directive({ + selector: '[appResizable]', +}) +export class StubResizableDirective { + readonly minWidth = input(); + readonly maxWidth = input(); + readonly defaultWidth = input(); + readonly storageKey = input(''); +} + +@Component({ + selector: 'app-global-favorites-list', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StubGlobalFavoritesListComponent { + readonly channels = input.required(); + readonly mode = input<'favorites' | 'recent'>('favorites'); + readonly showEpg = input(true); + readonly favoriteUids = input>(new Set()); + readonly epgMap = input>(new Map()); + readonly progressTick = input(0); + readonly activeUid = input(null); + readonly searchTermInput = input(''); + readonly draggable = input(true); + readonly sortMode = input( + DEFAULT_FAVORITES_CHANNEL_SORT_MODE + ); + + readonly channelSelected = output(); + readonly channelsReordered = output(); + readonly favoriteToggled = output(); + readonly removeRequested = output(); +} + +// Matches both live-panel selectors so the host's timeline ↔ list swap can be +// asserted by tag name; both branches share the identical contract. +@Component({ + selector: 'app-epg-timeline, app-epg-list-view', + template: '
', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StubEpgTimelineComponent { + readonly programs = input([]); + readonly channelName = input(''); + readonly channelLogo = input(''); + readonly archivePlaybackAvailable = input(false); + readonly archiveDays = input(0); + readonly activeProgram = input(null); + readonly isLivePlayback = input(true); + readonly selectedDate = input(null); + readonly collapsed = input(false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly summary = input(null); + readonly summaryLabelKey = input(''); + readonly selectedDateChange = output(); + readonly collapsedChange = output(); + readonly programActivated = output(); + readonly returnToLive = output(); +} + +@Component({ + selector: 'app-audio-player', + template: '
', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StubAudioPlayerComponent { + readonly icon = input(''); + readonly url = input.required(); + readonly channelName = input(''); +} + +@Component({ + selector: 'app-web-player-view', + template: '
', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StubWebPlayerViewComponent { + readonly playbackSessionKey = input.required(); + readonly streamUrl = input.required(); + readonly title = input(''); + readonly playback = input(null); + readonly playerOverride = input(null); + readonly recordingMetadata = input(null); + readonly externalFallbackRequested = output(); + readonly recordingStopped = output(); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 857b581af..9dfbfb0cb 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -190,9 +190,11 @@ [streamUrl]="streamUrl()" [title]="activePlayback()?.title ?? ''" [playback]="activePlayback()" + [recordingMetadata]="recordingMetadata()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) " + (recordingStopped)="onRecordingStopped($event)" /> } diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 92ace94cc..8574eb544 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -1,11 +1,4 @@ -import { - Component, - Directive, - EventEmitter, - input, - output, - signal, -} from '@angular/core'; +import { EventEmitter, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -18,10 +11,7 @@ import { } from '@iptvnator/portal/shared/util'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg'; -import { - AudioPlayerComponent, - type PlaybackFallbackRequest, -} from '@iptvnator/ui/playback'; +import { AudioPlayerComponent } from '@iptvnator/ui/playback'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { ChannelListItemComponent } from '@iptvnator/ui/components'; import { MockPipe } from 'ng-mocks'; @@ -40,103 +30,14 @@ import { MatDialog } from '@angular/material/dialog'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { WebPlayerViewComponent } from '@iptvnator/ui/playback'; import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component'; - -@Component({ - selector: 'app-channel-list-item', - standalone: true, - template: '', -}) -class StubChannelListItemComponent { - readonly name = input(''); - readonly logo = input(null); - readonly selected = input(false); - readonly showEpg = input(true); - readonly isRadio = input(false); - readonly epgProgram = input(null); - readonly progressPercentage = input(0); - readonly showFavoriteButton = input(false); - readonly showProgramInfoButton = input(false); - readonly showDetailsContextMenu = input(false); - readonly isFavorite = input(false); - readonly clicked = output(); - readonly activated = output(); - readonly favoriteToggled = output(); - readonly contextMenuRequested = output(); -} - -@Component({ - selector: 'app-web-player-view', - standalone: true, - template: '', -}) -class StubWebPlayerViewComponent { - readonly playbackSessionKey = input.required(); - readonly streamUrl = input(''); - readonly title = input(''); - readonly playback = input(null); - readonly externalFallbackRequested = output(); -} - -@Component({ - selector: 'app-audio-player', - standalone: true, - template: '', -}) -class StubAudioPlayerComponent { - readonly url = input.required(); - readonly icon = input(''); - readonly channelName = input(''); - readonly dispatchAdjacentChannelAction = input(true); - readonly channelSwitchRequested = output<'next' | 'previous'>(); -} - -// Matches both live-panel selectors so the host's timeline ↔ list swap can be -// asserted by tag name; both branches share the identical contract. -@Component({ - selector: 'app-epg-timeline, app-epg-list-view', - standalone: true, - template: ` -
{{ summary()?.title }}
- `, -}) -class StubEpgTimelineComponent { - readonly programs = input([]); - readonly channelName = input(''); - readonly channelLogo = input(''); - readonly sourceLabel = input(''); - readonly archivePlaybackAvailable = input(false); - readonly archiveDays = input(0); - readonly activeProgram = input(null); - readonly isLivePlayback = input(false); - readonly loading = input(false); - readonly emptyReason = input('none'); - readonly selectedDate = input(null); - readonly collapsed = input(false); - readonly summary = input<{ title?: string } | null>(null); - readonly summaryLabelKey = input(''); - readonly selectedDateChange = output(); - readonly programActivated = output(); - readonly returnToLive = output(); - readonly openEpgSettings = output(); - readonly retry = output(); - readonly collapsedChange = output(); -} - -@Component({ - selector: 'app-portal-empty-state', - standalone: true, - template: '', -}) -class StubPortalEmptyStateComponent { - readonly icon = input(''); - readonly message = input(''); -} - -@Directive({ - selector: '[appResizable]', - standalone: true, -}) -class StubResizableDirective {} +import { + StubAudioPlayerComponent, + StubChannelListItemComponent, + StubEpgTimelineComponent, + StubPortalEmptyStateComponent, + StubResizableDirective, + StubWebPlayerViewComponent, +} from './stalker-live-stream-layout.spec-stubs'; describe('StalkerLiveStreamLayoutComponent', () => { let fixture: ComponentFixture; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index e86e71a0a..8d6c6ac8c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -31,6 +31,7 @@ import { } from '@iptvnator/ui/components'; import { PlaylistsService, + RecordingsService, RuntimeCapabilitiesService, SettingsStore, } from '@iptvnator/services'; @@ -39,8 +40,13 @@ import { Channel, EpgItem, EpgProgram, + filterRecordingProgramsOverlap, + playlistDisplayLabel, + RecordingStartMetadata, + RecordingStoppedEvent, ResolvedPortalPlayback, StalkerPortalItem, + toRecordingProgramSnapshot, } from '@iptvnator/shared/interfaces'; import { EpgDateNavigationDirection, @@ -340,9 +346,77 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { fallbackPrograms ); }); - readonly currentProgram = computed(() => - this.findCurrentProgram(this.activeEpgPrograms()) - ); + /** + * 30 s clock read by `currentProgram`: `findCurrentProgram` compares + * against Date.now(), which a computed would otherwise cache — a + * recording started after an EPG boundary (or the panel summary and + * external-player metadata reading this) would keep the previous show. + */ + private readonly epgClockTick = signal(0); + private epgClockTimer: ReturnType | null = null; + readonly currentProgram = computed(() => { + this.epgClockTick(); + return this.findCurrentProgram(this.activeEpgPrograms()); + }); + private readonly recordingsService = inject(RecordingsService); + /** Channel/EPG snapshot for the embedded-MPV recording tracker. */ + readonly recordingMetadata = computed(() => { + const selectedItem = this.stalkerStore.selectedItem(); + if (!selectedItem?.id) { + return null; + } + const playlist = this.stalkerStore.currentPlaylist(); + const selectedType = this.stalkerStore.selectedContentType(); + // ITV only: the bulk EPG cache is ITV-keyed and Ministra reuses small + // integer ids across itv/radio, so a radio recording gets no program. + const program = + selectedType === 'itv' ? (this.currentProgram() ?? null) : null; + return { + channelName: + selectedItem.o_name?.trim() || + selectedItem.name?.trim() || + 'Live TV', + channelLogoUrl: this.activePlaybackArtwork() || undefined, + playlistId: playlist?._id, + playlistName: playlistDisplayLabel(playlist?.title) || undefined, + sourceType: 'stalker', + epgChannelId: playlist?._id + ? buildStalkerEpgMappingKey( + playlist._id, + String(selectedItem.id) + ) + : undefined, + currentProgram: program + ? toRecordingProgramSnapshot(program) + : undefined, + }; + }); + + /** Stop enrichment: programs overlapping the recorded window. */ + onRecordingStopped(event: RecordingStoppedEvent): void { + if (this.stalkerStore.selectedContentType() !== 'itv') { + return; + } + // A channel switch auto-stops the recording, and by now this host + // already describes the new channel — enriching then would attach the + // wrong schedule (and could promote an unrelated program to the + // recording's title). + if ( + event.epgChannelId && + event.epgChannelId !== this.recordingMetadata()?.epgChannelId + ) { + return; + } + const programs = filterRecordingProgramsOverlap( + this.activeEpgPrograms().map(toRecordingProgramSnapshot), + event.startedAt, + event.endedAt + ); + if (programs.length === 0) { + return; + } + void this.recordingsService.updatePrograms(event.targetPath, programs); + } readonly liveEpgPanelState = signal( restoreLiveEpgPanelState() ); @@ -431,6 +505,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { private lastPlaylistId: string | null | undefined = undefined; constructor() { + this.epgClockTimer = setInterval( + () => this.epgClockTick.update((tick) => tick + 1), + 30_000 + ); + // Load favorites for current playlist const playlistId = this.stalkerStore.currentPlaylist()?._id; if (playlistId) { @@ -644,6 +723,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { REMOTE_CONTROL_RESET_STATUS ); this.removeScrollListener(); + if (this.epgClockTimer !== null) { + clearInterval(this.epgClockTimer); + this.epgClockTimer = null; + } if (this.epgPreviewRefreshTimer !== null) { clearTimeout(this.epgPreviewRefreshTimer); this.epgPreviewRefreshTimer = null; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.spec-stubs.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.spec-stubs.ts new file mode 100644 index 000000000..d0e44cf2e --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.spec-stubs.ts @@ -0,0 +1,112 @@ +import { Component, Directive, input, output } from '@angular/core'; +import { type PlaybackFallbackRequest } from '@iptvnator/ui/playback'; +import { + EpgProgram, + RecordingStartMetadata, + RecordingStoppedEvent, +} from '@iptvnator/shared/interfaces'; + +/** + * Stand-ins for the children StalkerLiveStreamLayoutComponent renders, mirroring + * the real inputs/outputs so specs can assert what the host hands each child + * without pulling in the players or the EPG timeline. + */ + +@Component({ + selector: 'app-channel-list-item', + standalone: true, + template: '', +}) +export class StubChannelListItemComponent { + readonly name = input(''); + readonly logo = input(null); + readonly selected = input(false); + readonly showEpg = input(true); + readonly isRadio = input(false); + readonly epgProgram = input(null); + readonly progressPercentage = input(0); + readonly showFavoriteButton = input(false); + readonly showProgramInfoButton = input(false); + readonly showDetailsContextMenu = input(false); + readonly isFavorite = input(false); + readonly clicked = output(); + readonly activated = output(); + readonly favoriteToggled = output(); + readonly contextMenuRequested = output(); +} + +@Component({ + selector: 'app-web-player-view', + standalone: true, + template: '', +}) +export class StubWebPlayerViewComponent { + readonly playbackSessionKey = input.required(); + readonly streamUrl = input(''); + readonly title = input(''); + readonly playback = input(null); + readonly recordingMetadata = input(null); + readonly externalFallbackRequested = output(); + readonly recordingStopped = output(); +} + +@Component({ + selector: 'app-audio-player', + standalone: true, + template: '', +}) +export class StubAudioPlayerComponent { + readonly url = input.required(); + readonly icon = input(''); + readonly channelName = input(''); + readonly dispatchAdjacentChannelAction = input(true); + readonly channelSwitchRequested = output<'next' | 'previous'>(); +} + +// Matches both live-panel selectors so the host's timeline ↔ list swap can be +// asserted by tag name; both branches share the identical contract. +@Component({ + selector: 'app-epg-timeline, app-epg-list-view', + standalone: true, + template: ` +
{{ summary()?.title }}
+ `, +}) +export class StubEpgTimelineComponent { + readonly programs = input([]); + readonly channelName = input(''); + readonly channelLogo = input(''); + readonly sourceLabel = input(''); + readonly archivePlaybackAvailable = input(false); + readonly archiveDays = input(0); + readonly activeProgram = input(null); + readonly isLivePlayback = input(false); + readonly loading = input(false); + readonly emptyReason = input('none'); + readonly selectedDate = input(null); + readonly collapsed = input(false); + readonly summary = input<{ title?: string } | null>(null); + readonly summaryLabelKey = input(''); + readonly selectedDateChange = output(); + readonly programActivated = output(); + readonly returnToLive = output(); + readonly openEpgSettings = output(); + readonly retry = output(); + readonly collapsedChange = output(); +} + +@Component({ + selector: 'app-portal-empty-state', + standalone: true, + template: '', +}) +export class StubPortalEmptyStateComponent { + readonly icon = input(''); + readonly message = input(''); +} + +@Directive({ + selector: '[appResizable]', + standalone: true, +}) +export class StubResizableDirective {} diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts index 529b327e1..abf727b57 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts @@ -37,6 +37,35 @@ const initialEpgState: EpgState = { * - Loading EPG for selected stream * - Loading channel EPG for preview */ +/** + * Pure current-program selection over `epgItems`: first item (by start) with + * `start <= now < stop`. Exported for clock-driven callers — the live + * layout's recording start snapshot re-evaluates it against its own 30 s + * tick, because the `currentEpgItem` computed below caches its `Date.now()` + * verdict until `epgItems` changes and would hand a recording started after + * an EPG boundary the previous show. + */ +export function findCurrentEpgItem( + items: readonly EpgItem[], + nowMs: number +): EpgItem | null { + const sorted = [...items].sort( + (left, right) => + getEpgTimestampMs(left.start, left.start_timestamp) - + getEpgTimestampMs(right.start, right.start_timestamp) + ); + return ( + sorted.find((item) => { + const start = getEpgTimestampMs(item.start, item.start_timestamp); + const stop = getEpgTimestampMs( + item.stop ?? item.end, + item.stop_timestamp + ); + return nowMs >= start && nowMs < stop; + }) ?? null + ); +} + export function withEpg() { const logger = createLogger('withEpg'); type ParentSelectionStoreLike = { @@ -50,28 +79,9 @@ export function withEpg() { return signalStoreFeature( withState(initialEpgState), withComputed((store) => ({ - currentEpgItem: computed(() => { - const now = Date.now(); - const items = [...store.epgItems()].sort( - (left, right) => - getEpgTimestampMs(left.start, left.start_timestamp) - - getEpgTimestampMs(right.start, right.start_timestamp) - ); - - return ( - items.find((item) => { - const start = getEpgTimestampMs( - item.start, - item.start_timestamp - ); - const stop = getEpgTimestampMs( - item.stop ?? item.end, - item.stop_timestamp - ); - return now >= start && now < stop; - }) ?? null - ); - }), + currentEpgItem: computed(() => + findCurrentEpgItem(store.epgItems(), Date.now()) + ), })), withMethods((store) => { diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index cb0446e31..a59066935 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -100,9 +100,11 @@ [streamUrl]="streamUrl" [title]="activePlayback()?.title ?? ''" [playback]="activePlayback()" + [recordingMetadata]="recordingMetadata()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) " + (recordingStopped)="onRecordingStopped($event)" /> } diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index be66cfac8..86806819e 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -36,6 +36,8 @@ import { import { EpgItem, EpgProgram, + RecordingStartMetadata, + RecordingStoppedEvent, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { GridListComponent } from '@iptvnator/portal/shared/ui'; @@ -86,7 +88,9 @@ class StubWebPlayerViewComponent { readonly streamUrl = input(''); readonly title = input(''); readonly playback = input(null); + readonly recordingMetadata = input(null); readonly externalFallbackRequested = output(); + readonly recordingStopped = output(); } // Matches both live-panel selectors so the host's timeline ↔ list swap can be diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index ca971872c..d1740fdd2 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -45,6 +45,7 @@ import { import { FavoriteItem, FavoritesService, + findCurrentEpgItem, XtreamUrlService, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; @@ -63,13 +64,23 @@ import { } from '@iptvnator/ui/playback'; import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; import { + buildXtreamEpgMappingKey, EpgItem, EpgProgram, + filterRecordingProgramsOverlap, + playlistDisplayLabel, + RecordingStartMetadata, + RecordingStoppedEvent, ResolvedPortalPlayback, + toRecordingProgramSnapshot, } from '@iptvnator/shared/interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; -import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { + RecordingsService, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service'; import { createPlaybackSessionKey } from '@iptvnator/playback/util'; @@ -169,6 +180,67 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly controlledEpgPrograms = computed(() => this.epgItems().map((program) => this.toControlledEpgProgram(program)) ); + private readonly recordingsService = inject(RecordingsService); + /** Channel/EPG snapshot for the embedded-MPV recording tracker. */ + readonly recordingMetadata = computed(() => { + const item = this.selectedLiveItem(); + if (!item) { + return null; + } + const playlist = this.xtreamStore.currentPlaylist(); + // Re-select against the 30 s tick: the store's `currentEpgItem` + // caches its Date.now() verdict until epgItems changes, so a + // recording started after an EPG boundary would snapshot the + // previous show. + const program = findCurrentEpgItem( + this.epgItems(), + this.currentTimeMs() + ); + return { + channelName: item.title?.trim() || item.name?.trim() || 'Live TV', + channelLogoUrl: + item.poster_url?.trim() || item.stream_icon?.trim() || undefined, + playlistId: playlist?.id, + playlistName: + playlistDisplayLabel(playlist?.name ?? playlist?.title) || + undefined, + sourceType: 'xtream', + epgChannelId: playlist + ? buildXtreamEpgMappingKey(playlist.id, item.xtream_id) + : undefined, + currentProgram: program + ? toRecordingProgramSnapshot({ + title: program.title, + description: program.description, + start: program.start, + stop: program.stop ?? program.end, + }) + : undefined, + }; + }); + + /** Stop enrichment: programs overlapping the recorded window. */ + onRecordingStopped(event: RecordingStoppedEvent): void { + // A channel switch auto-stops the recording, and by now this host + // already describes the new channel — enriching then would attach the + // wrong schedule (and could promote an unrelated program to the + // recording's title). + if ( + event.epgChannelId && + event.epgChannelId !== this.recordingMetadata()?.epgChannelId + ) { + return; + } + const programs = filterRecordingProgramsOverlap( + this.controlledEpgPrograms().map(toRecordingProgramSnapshot), + event.startedAt, + event.endedAt + ); + if (programs.length === 0) { + return; + } + void this.recordingsService.updatePrograms(event.targetPath, programs); + } private readonly currentTimeMs = signal(Date.now()); readonly activeCatchupProgram = signal(null); readonly controlledArchiveDays = computed(() => diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index b0e108bae..8126c9c86 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -4,6 +4,7 @@ export * from './lib/data.service'; export * from './lib/host-connectivity-reset'; export * from './lib/database-electron.service'; export * from './lib/downloads.service'; +export * from './lib/recordings.service'; export * from './lib/playback-position-runtime-bridge.service'; export * from './lib/playback-position.service'; export * from './lib/playlist-delete-cleanup.token'; diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index 904ca9890..2e40f869e 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -71,16 +71,18 @@ export class DownloadsService implements OnDestroy { return; } + // Subscribe BEFORE the initial load, so a transition pinged while + // that request is pending is coalesced into a trailing refresh + // instead of being lost with the pre-transition response. + this.unsubscribe = window.electron.onDownloadsUpdate(() => { + this.loadDownloads(); + }); + // Load initial download list await this.loadDownloads(); // Load download folder await this.loadDownloadFolder(); - - // Subscribe to download updates - this.unsubscribe = window.electron.onDownloadsUpdate(() => { - this.loadDownloads(); - }); } ngOnDestroy() { diff --git a/libs/services/src/lib/recordings.models.ts b/libs/services/src/lib/recordings.models.ts new file mode 100644 index 000000000..bb83061b3 --- /dev/null +++ b/libs/services/src/lib/recordings.models.ts @@ -0,0 +1,14 @@ +import type { ElectronRecordingItem } from '@iptvnator/shared/interfaces'; + +/** + * Renderer-facing live-TV recording row. The bridge type is already + * camelCased and carries the decoded `programs` array plus the derived + * `fileAvailability`, so the renderer model is a direct alias. + */ +export type RecordingItem = ElectronRecordingItem; + +export type { + RecordingProgramSnapshot, + RecordingSourceType, + RecordingStatus, +} from '@iptvnator/shared/interfaces'; diff --git a/libs/services/src/lib/recordings.service.spec.ts b/libs/services/src/lib/recordings.service.spec.ts new file mode 100644 index 000000000..e85bea5d5 --- /dev/null +++ b/libs/services/src/lib/recordings.service.spec.ts @@ -0,0 +1,348 @@ +import { + EnvironmentInjector, + Injector, + Signal, + WritableSignal, + createEnvironmentInjector, + runInInjectionContext, + signal, +} from '@angular/core'; +import type { ElectronBridgeErrorResult } from '@iptvnator/shared/interfaces'; +import { DownloadListLoadState } from './download-list-load-state'; +import { RecordingsService } from './recordings.service'; +import type { RecordingItem } from './recordings.service'; +import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; + +type TestRecordingsService = { + recordings: WritableSignal; + isAvailable: () => boolean; + isLoadingRecordings: Signal; + hasLoadedRecordings: Signal; + hasAuthoritativeRecordingList: Signal; + listLoadState: DownloadListLoadState; + loadRecordings: RecordingsService['loadRecordings']; + stopRecording: RecordingsService['stopRecording']; + removeRecording: RecordingsService['removeRecording']; + updatePrograms: RecordingsService['updatePrograms']; + revealFile: RecordingsService['revealFile']; + playFile: RecordingsService['playFile']; +}; + +type RecordingsElectronStub = { + recordingsGetList?: jest.Mock; + recordingsStop?: jest.Mock; + recordingsRemove?: jest.Mock; + recordingsUpdatePrograms?: jest.Mock; + recordingsRevealFile?: jest.Mock; + recordingsPlayFile?: jest.Mock; + onRecordingsUpdate?: jest.Mock; +}; + +describe('RecordingsService', () => { + const testWindow = window as unknown as { + electron?: RecordingsElectronStub; + }; + const originalElectron = testWindow.electron; + + afterEach(() => { + testWindow.electron = originalElectron; + jest.restoreAllMocks(); + }); + + function createRecording( + id: number, + status: RecordingItem['status'] = 'completed', + overrides: Partial = {} + ): RecordingItem { + return { + id, + status, + filePath: `/recordings/${id}.ts`, + channelName: `Channel ${id}`, + startedAt: '2026-08-15T12:00:00Z', + fileAvailability: 'available', + ...overrides, + }; + } + + function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; + } + + /** Constructor-driven instance: init() runs against the given capability. */ + function createInjectedService(supportsRecordings: boolean) { + const injector = createEnvironmentInjector( + [ + RecordingsService, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsRecordings }, + }, + ], + Injector.NULL as unknown as EnvironmentInjector + ); + const service = runInInjectionContext( + injector, + () => new RecordingsService() + ); + return { injector, service }; + } + + /** Prototype-based instance: no constructor, so init() cannot interfere. */ + function createService(initial: RecordingItem[] = []) { + const listLoadState = new DownloadListLoadState(); + const service = Object.create( + RecordingsService.prototype + ) as TestRecordingsService; + Object.assign(service, { + recordings: signal(initial), + isAvailable: () => true, + listLoadState, + isLoadingRecordings: listLoadState.isLoading, + hasLoadedRecordings: listLoadState.hasLoaded, + hasAuthoritativeRecordingList: listLoadState.hasAuthoritativeList, + }); + return service; + } + + const flush = () => + new Promise((resolve) => setTimeout(resolve, 0)); + + it('never touches the bridge when recordings are unavailable', async () => { + const electron: RecordingsElectronStub = { + recordingsGetList: jest.fn(async () => [createRecording(1)]), + recordingsStop: jest.fn(async () => ({ success: true })), + onRecordingsUpdate: jest.fn(), + }; + testWindow.electron = electron; + const { injector, service } = createInjectedService(false); + + try { + await flush(); + await service.loadRecordings(); + + expect(service.isAvailable()).toBe(false); + expect(service.recordings()).toEqual([]); + expect(service.hasRecordings()).toBe(false); + expect(electron.recordingsGetList).not.toHaveBeenCalled(); + expect(electron.onRecordingsUpdate).not.toHaveBeenCalled(); + + await expect(service.stopRecording(1)).resolves.toEqual({ + error: 'Recordings are not available', + success: false, + }); + expect(electron.recordingsStop).not.toHaveBeenCalled(); + } finally { + injector.destroy(); + } + }); + + it('loads once on init, subscribes to update pings, and unsubscribes on destroy', async () => { + const initial = createRecording(1); + const updated = createRecording(2, 'recording'); + let pushUpdate: (() => void) | undefined; + const unsubscribe = jest.fn(); + const electron: RecordingsElectronStub = { + recordingsGetList: jest + .fn() + .mockResolvedValueOnce([initial]) + .mockResolvedValueOnce([updated]), + onRecordingsUpdate: jest.fn((callback: () => void) => { + pushUpdate = callback; + return unsubscribe; + }), + }; + testWindow.electron = electron; + const { injector, service } = createInjectedService(true); + + try { + await flush(); + + expect(electron.recordingsGetList).toHaveBeenCalledTimes(1); + expect(electron.onRecordingsUpdate).toHaveBeenCalledTimes(1); + expect(service.recordings()).toEqual([initial]); + expect(service.hasLoadedRecordings()).toBe(true); + expect(service.hasAuthoritativeRecordingList()).toBe(true); + + pushUpdate?.(); + await flush(); + + expect(electron.recordingsGetList).toHaveBeenCalledTimes(2); + expect(service.recordings()).toEqual([updated]); + + service.ngOnDestroy(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + } finally { + injector.destroy(); + } + }); + + it('coalesces overlapping loads into one in-flight plus one trailing refresh', async () => { + const inFlightItem = createRecording(1); + const trailingItem = createRecording(2); + const first = createDeferred(); + const second = createDeferred(); + const electron: RecordingsElectronStub = { + recordingsGetList: jest + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise), + }; + testWindow.electron = electron; + const service = createService(); + + const firstRequest = service.loadRecordings(); + const secondRequest = service.loadRecordings(); + const thirdRequest = service.loadRecordings(); + + expect(service.isLoadingRecordings()).toBe(true); + expect(electron.recordingsGetList).toHaveBeenCalledTimes(1); + + first.resolve([inFlightItem]); + await firstRequest; + + expect(service.recordings()).toEqual([inFlightItem]); + expect(service.isLoadingRecordings()).toBe(true); + expect(electron.recordingsGetList).toHaveBeenCalledTimes(2); + + second.resolve([trailingItem]); + await secondRequest; + await thirdRequest; + + expect(service.recordings()).toEqual([trailingItem]); + expect(service.isLoadingRecordings()).toBe(false); + expect(service.hasAuthoritativeRecordingList()).toBe(true); + expect(electron.recordingsGetList).toHaveBeenCalledTimes(2); + }); + + it('marks a failed list load non-authoritative while preserving the list and logging', async () => { + const existing = createRecording(1); + const error = new Error('recordings query failed'); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + testWindow.electron = { + recordingsGetList: jest.fn().mockRejectedValue(error), + }; + const service = createService([existing]); + service.listLoadState.markSucceeded(); + + await service.loadRecordings(); + + expect(service.recordings()).toEqual([existing]); + expect(service.isLoadingRecordings()).toBe(false); + expect(service.hasAuthoritativeRecordingList()).toBe(false); + expect(service.hasLoadedRecordings()).toBe(true); + expect(console.error).toHaveBeenCalledWith( + '[RecordingsService] Error loading recordings:', + error + ); + }); + + it('normalizes missing bridge methods to a safe failure result', async () => { + const electron: RecordingsElectronStub = { + recordingsGetList: jest.fn(async () => []), + }; + testWindow.electron = electron; + const service = createService(); + const unavailable: ElectronBridgeErrorResult = { + error: 'Recordings bridge unavailable', + success: false, + }; + + await expect(service.stopRecording(1)).resolves.toEqual(unavailable); + await expect(service.removeRecording(1)).resolves.toEqual(unavailable); + await expect( + service.updatePrograms('/recordings/1.ts', []) + ).resolves.toEqual(unavailable); + await expect( + service.revealFile('/recordings/1.ts') + ).resolves.toEqual(unavailable); + await expect( + service.playFile('/recordings/1.ts') + ).resolves.toEqual(unavailable); + expect(electron.recordingsGetList).not.toHaveBeenCalled(); + }); + + it('passes through action results and refreshes only after a successful remove', async () => { + const remaining = createRecording(2); + const electron: RecordingsElectronStub = { + recordingsGetList: jest.fn(async () => [remaining]), + recordingsStop: jest.fn(async () => ({ success: true })), + recordingsRemove: jest.fn(async () => ({ success: true })), + }; + testWindow.electron = electron; + const service = createService([ + createRecording(1), + createRecording(2), + ]); + + await expect(service.stopRecording(1)).resolves.toEqual({ + success: true, + }); + expect(electron.recordingsStop).toHaveBeenCalledWith(1); + expect(electron.recordingsGetList).not.toHaveBeenCalled(); + + await expect(service.removeRecording(1)).resolves.toEqual({ + success: true, + }); + expect(electron.recordingsRemove).toHaveBeenCalledWith(1); + expect(electron.recordingsGetList).toHaveBeenCalledTimes(1); + expect(service.recordings()).toEqual([remaining]); + }); + + it('does not refresh after a failed remove and reports a thrown action safely', async () => { + const error = new Error('stop failed'); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + const electron: RecordingsElectronStub = { + recordingsGetList: jest.fn(async () => []), + recordingsRemove: jest.fn(async () => ({ + error: 'row is busy', + success: false, + })), + recordingsStop: jest.fn().mockRejectedValue(error), + }; + testWindow.electron = electron; + const service = createService(); + + await expect(service.removeRecording(1)).resolves.toEqual({ + error: 'row is busy', + success: false, + }); + expect(electron.recordingsGetList).not.toHaveBeenCalled(); + + await expect(service.stopRecording(1)).resolves.toEqual({ + error: 'stop failed', + success: false, + }); + expect(console.error).toHaveBeenCalledWith( + '[RecordingsService] Error (stop):', + error + ); + }); + + it('picks the row with status recording as the active recording', () => { + const { injector, service } = createInjectedService(false); + + try { + const active = createRecording(2, 'recording'); + service.recordings.set([ + createRecording(1), + active, + createRecording(3, 'failed'), + ]); + + expect(service.activeRecording()).toBe(active); + expect(service.hasRecordings()).toBe(true); + + service.recordings.set([createRecording(1)]); + expect(service.activeRecording()).toBeNull(); + } finally { + injector.destroy(); + } + }); +}); diff --git a/libs/services/src/lib/recordings.service.ts b/libs/services/src/lib/recordings.service.ts new file mode 100644 index 000000000..74f4a9a5b --- /dev/null +++ b/libs/services/src/lib/recordings.service.ts @@ -0,0 +1,161 @@ +import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core'; +import type { + ElectronBridgeErrorResult, + RecordingProgramSnapshot, +} from '@iptvnator/shared/interfaces'; +import { DownloadListLoadState } from './download-list-load-state'; +import type { RecordingItem } from './recordings.models'; +import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; + +export type { RecordingItem } from './recordings.models'; + +/** + * Global store for live-TV recordings, mirroring DownloadsService: one + * authoritative list refreshed on RECORDINGS_UPDATE_EVENT pings, with + * overlapping loads coalesced behind one serialized trailing refresh. + */ +@Injectable({ providedIn: 'root' }) +export class RecordingsService implements OnDestroy { + private readonly runtime = inject(RuntimeCapabilitiesService); + private unsubscribe?: () => void; + private readonly listLoadState = new DownloadListLoadState(); + + readonly recordings = signal([]); + readonly isLoadingRecordings = this.listLoadState.isLoading; + readonly hasLoadedRecordings = this.listLoadState.hasLoaded; + readonly hasAuthoritativeRecordingList = + this.listLoadState.hasAuthoritativeList; + + /** Whether the recordings feature is available (Electron only). */ + readonly isAvailable = computed(() => this.runtime.supportsRecordings); + + readonly hasRecordings = computed(() => this.recordings().length > 0); + + /** The recording currently in progress, if any. */ + readonly activeRecording = computed( + () => this.recordings().find((r) => r.status === 'recording') ?? null + ); + + constructor() { + this.init(); + } + + private async init() { + if (!this.isAvailable()) { + return; + } + + // Subscribe BEFORE the initial load: a recording transition during + // that request pings while the response still reflects the older + // database state, and the load-state coalescing can only queue the + // trailing refresh if the listener already exists. Recording pings + // are rare, so a missed one would not self-heal until the 15 s poll + // (armed only once an active row is already visible). + this.unsubscribe = window.electron.onRecordingsUpdate?.(() => { + this.loadRecordings(); + }); + await this.loadRecordings(); + } + + ngOnDestroy() { + this.unsubscribe?.(); + } + + async loadRecordings(): Promise { + if (!this.isAvailable()) return; + + return this.listLoadState.run(async () => { + try { + const list = await window.electron.recordingsGetList?.(); + this.recordings.set(list ?? []); + this.listLoadState.markSucceeded(); + } catch (error) { + console.error( + '[RecordingsService] Error loading recordings:', + error + ); + this.listLoadState.markFailed(); + } + }); + } + + async getRecording(recordingId: number): Promise { + if (!this.isAvailable()) return null; + try { + return (await window.electron.recordingsGet?.(recordingId)) ?? null; + } catch (error) { + console.error( + '[RecordingsService] Error getting recording:', + error + ); + return null; + } + } + + async stopRecording( + recordingId: number + ): Promise { + return this.runAction('stop', () => + window.electron.recordingsStop?.(recordingId) + ); + } + + async removeRecording( + recordingId: number + ): Promise { + return this.runAction( + 'remove', + () => window.electron.recordingsRemove?.(recordingId), + // The ping fires from the backend too, but refreshing directly + // keeps the UI honest if the broadcast is missed. + true + ); + } + + async updatePrograms( + targetPath: string, + programs: RecordingProgramSnapshot[] + ): Promise { + return this.runAction('update programs', () => + window.electron.recordingsUpdatePrograms?.(targetPath, programs) + ); + } + + async revealFile(filePath: string): Promise { + return this.runAction('reveal', () => + window.electron.recordingsRevealFile?.(filePath) + ); + } + + async playFile(filePath: string): Promise { + return this.runAction('play', () => + window.electron.recordingsPlayFile?.(filePath) + ); + } + + private async runAction( + label: string, + action: () => Promise | undefined, + refreshAfter = false + ): Promise { + if (!this.isAvailable()) { + return { error: 'Recordings are not available', success: false }; + } + try { + const result = (await action()) ?? { + error: 'Recordings bridge unavailable', + success: false, + }; + if (refreshAfter && result.success) { + await this.loadRecordings(); + } + return result; + } catch (error) { + console.error(`[RecordingsService] Error (${label}):`, error); + return { + error: error instanceof Error ? error.message : String(error), + success: false, + }; + } + } +} diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index b6e659fbf..a53a3e210 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -395,6 +395,39 @@ describe('RuntimeCapabilitiesService', () => { expect(service.supportsDownloads).toBe(true); }); + it('gates recordings separately from the downloads allowlist', () => { + testWindow.electron = { + recordingsGetList: jest.fn(), + }; + + const service = new RuntimeCapabilitiesService(); + + expect(service.isElectron).toBe(true); + expect(service.supportsRecordings).toBe(false); + + testWindow.electron = { + recordingsGetList: jest.fn(), + recordingsGet: jest.fn(), + recordingsStop: jest.fn(), + recordingsRemove: jest.fn(), + recordingsUpdatePrograms: jest.fn(), + recordingsRevealFile: jest.fn(), + recordingsPlayFile: jest.fn(), + }; + + expect(service.supportsRecordings).toBe(false); + + testWindow.electron = { + ...testWindow.electron, + onRecordingsUpdate: jest.fn(), + }; + + expect(service.supportsRecordings).toBe(true); + // An older build without the recordings bridge keeps the download + // manager: the downloads allowlist is untouched by recordings. + expect(service.supportsDownloads).toBe(false); + }); + it('requires both desktop file-save preload methods', () => { testWindow.electron = { saveFileDialog: jest.fn(), diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index 41da43352..0299e466b 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -204,6 +204,22 @@ export class RuntimeCapabilitiesService { ].every((methodName) => this.hasElectronMethod(methodName)); } + // Deliberately separate from supportsDownloads: that allowlist is + // all-or-nothing, and folding the recordings bridge into it would make + // older Electron builds lose the entire download manager. + get supportsRecordings(): boolean { + return [ + 'recordingsGetList', + 'recordingsGet', + 'recordingsStop', + 'recordingsRemove', + 'recordingsUpdatePrograms', + 'recordingsRevealFile', + 'recordingsPlayFile', + 'onRecordingsUpdate', + ].every((methodName) => this.hasElectronMethod(methodName)); + } + get supportsPortalActivityStorage(): boolean { return [ 'dbGetRecentlyViewed', diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 006bf26af..01fc74b92 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -119,6 +119,37 @@ const DOWNLOADS_INDEX_STATEMENTS = [ `CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`, `CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`, ]; +// No unique index: re-recording the same channel is a normal workflow, and +// playlist_id carries no FK so recordings survive source deletion. +const RECORDINGS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + owner_pid INTEGER, + status TEXT NOT NULL DEFAULT 'recording' CHECK (status IN ('recording', 'completed', 'interrupted', 'failed')), + file_path TEXT NOT NULL, + file_size_bytes INTEGER, + channel_name TEXT NOT NULL, + channel_logo_url TEXT, + playlist_id TEXT, + playlist_name TEXT, + source_type TEXT CHECK (source_type IN ('m3u', 'xtream', 'stalker')), + epg_channel_id TEXT, + program_title TEXT, + program_description TEXT, + program_start TEXT, + program_stop TEXT, + programs_json TEXT, + error_message TEXT, + started_at TEXT NOT NULL, + ended_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + )`; +const RECORDINGS_INDEX_STATEMENTS = [ + `CREATE INDEX IF NOT EXISTS recordings_status_idx ON recordings(status)`, + `CREATE INDEX IF NOT EXISTS recordings_file_path_idx ON recordings(file_path)`, + `CREATE INDEX IF NOT EXISTS recordings_playlist_idx ON recordings(playlist_id)`, +]; const CREATE_TABLE_STATEMENTS = [ `CREATE TABLE IF NOT EXISTS playlists ( @@ -344,6 +375,9 @@ const CREATE_TABLE_STATEMENTS = [ // Downloads table DOWNLOADS_TABLE_SQL, ...DOWNLOADS_INDEX_STATEMENTS, + // Live-TV recordings table + RECORDINGS_TABLE_SQL, + ...RECORDINGS_INDEX_STATEMENTS, // TMDB metadata cache (details payloads + search match resolutions) TMDB_METADATA_TABLE_SQL, TMDB_METADATA_INDEX_SQL, diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index ec69a15fa..93771f49d 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -371,6 +371,61 @@ export const downloads = sqliteTable( export type Download = typeof downloads.$inferSelect; export type NewDownload = typeof downloads.$inferInsert; +// Live-TV recordings table. Rows are created by the embedded-MPV recording +// tracker, not by the download queue: a recording has no source URL to +// re-fetch, no byte totals, and no retry/resume semantics, so it lives beside +// `downloads` instead of inside it. playlist_id has no FK on purpose — +// recordings outlive the deletion of their source playlist. +export const recordings = sqliteTable( + 'recordings', + { + id: integer('id').primaryKey({ autoIncrement: true }), + // Transient embedded-MPV session id, correlation/debugging only. + sessionId: text('session_id'), + // PID of the process that opened the recording. Startup recovery uses + // it to leave rows owned by another live instance alone + // (IPTVNATOR_ALLOW_MULTIPLE_INSTANCES). + ownerPid: integer('owner_pid'), + status: text('status', { + enum: ['recording', 'completed', 'interrupted', 'failed'], + }) + .notNull() + .default('recording'), + filePath: text('file_path').notNull(), + fileSizeBytes: integer('file_size_bytes'), + // Metadata snapshot captured at recording start (EPG is + // time-sensitive; it cannot be reconstructed after the fact). + channelName: text('channel_name').notNull(), + channelLogoUrl: text('channel_logo_url'), + playlistId: text('playlist_id'), + playlistName: text('playlist_name'), + sourceType: text('source_type', { + enum: ['m3u', 'xtream', 'stalker'], + }), + epgChannelId: text('epg_channel_id'), + programTitle: text('program_title'), + programDescription: text('program_description'), + programStart: text('program_start'), + programStop: text('program_stop'), + // Stop-time enrichment: JSON array of program snapshots overlapping + // [started_at, ended_at] (a recording can span program boundaries). + programsJson: text('programs_json'), + errorMessage: text('error_message'), + startedAt: text('started_at').notNull(), + endedAt: text('ended_at'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`), + }, + (table) => ({ + statusIdx: index('recordings_status_idx').on(table.status), + filePathIdx: index('recordings_file_path_idx').on(table.filePath), + playlistIdx: index('recordings_playlist_idx').on(table.playlistId), + }) +); + +export type Recording = typeof recordings.$inferSelect; +export type NewRecording = typeof recordings.$inferInsert; + // TMDB metadata cache table. // Two row kinds share the table, discriminated by the lookup_key prefix: // - 'id:' → full TMDB details payload (JSON) diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 2a076185c..8c0c81074 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -40,6 +40,8 @@ export * from './lib/portal-playback.interface'; export * from './lib/provider-import-detection.util'; export * from './lib/provider-overview.util'; export * from './lib/random-id.util'; +export * from './lib/recording-metadata.interface'; +export * from './lib/recording-program-overlap.util'; export * from './lib/security-policy-error.utils'; export * from './lib/settings.interface'; export * from './lib/stalker-auth-failure.util'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index d2c91a01e..be6e34dee 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -15,6 +15,11 @@ import { GlobalSearchResultSource, } from './global-search-result.interface'; import { M3uFavoriteChannel } from './m3u-favorite-channel.interface'; +import { + RecordingProgramSnapshot, + RecordingSourceType, + RecordingStatus, +} from './recording-metadata.interface'; import { PlaybackPositionData } from './playback-position.interface'; import { AutoUpdatePlaylistsResult } from './playlist-auto-update.interface'; import { @@ -600,6 +605,41 @@ export interface ElectronDownloadItem { updatedAt?: string; } +/** + * Renderer-facing live-TV recording row. `fileAvailability` is derived at + * read time (never persisted) and `programs` is the decoded `programs_json` + * column. Recordings reuse the download availability vocabulary so the + * manager UI can share its missing-file affordances — extended by + * `'unknown'` for an inconclusive probe (timeout, permission or I/O error): + * only proven absence may move a recording to Needs attention or hide its + * file actions, so consumers gate on `=== 'missing'`, never on + * `!== 'available'`. + */ +export interface ElectronRecordingItem { + id: number; + sessionId?: string; + status: RecordingStatus; + filePath: string; + fileSizeBytes?: number; + channelName: string; + channelLogoUrl?: string; + playlistId?: string; + playlistName?: string; + sourceType?: RecordingSourceType; + epgChannelId?: string; + programTitle?: string; + programDescription?: string; + programStart?: string; + programStop?: string; + programs?: RecordingProgramSnapshot[]; + errorMessage?: string; + startedAt: string; + endedAt?: string; + createdAt?: string; + updatedAt?: string; + fileAvailability: ElectronDownloadFileAvailability | 'unknown'; +} + export interface ElectronBridgeApi { onPortalDebugEvent?: ( callback: (data: PortalDebugEvent) => void @@ -1185,4 +1225,28 @@ export interface ElectronBridgeApi { playlistId?: string ) => Promise; onDownloadsUpdate: (callback: () => void) => () => void; + // Live-TV recordings surface. Optional: older Electron builds have no + // recordings bridge, and `supportsRecordings` (not `supportsDownloads`) + // gates every consumer. + recordingsGetList?: ( + playlistId?: string + ) => Promise; + recordingsGet?: ( + recordingId: number + ) => Promise; + recordingsStop?: (recordingId: number) => Promise; + recordingsRemove?: ( + recordingId: number + ) => Promise; + recordingsUpdatePrograms?: ( + targetPath: string, + programs: RecordingProgramSnapshot[] + ) => Promise; + recordingsRevealFile?: ( + filePath: string + ) => Promise; + recordingsPlayFile?: ( + filePath: string + ) => Promise; + onRecordingsUpdate?: (callback: () => void) => () => void; } diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts index 74fd73112..0c2b4c2fe 100644 --- a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts +++ b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts @@ -1,3 +1,5 @@ +import type { RecordingStartMetadata } from './recording-metadata.interface'; + export type EmbeddedMpvSessionStatus = | 'idle' | 'loading' @@ -83,6 +85,12 @@ export interface EmbeddedMpvRecordingState { export interface EmbeddedMpvRecordingStartOptions { directory?: string; title?: string; + /** + * Channel/EPG snapshot captured by the live host at recording start; the + * main-process recording tracker persists it. See + * recording-metadata.interface.ts for why capture must happen up front. + */ + metadata?: RecordingStartMetadata; } export interface EmbeddedMpvSession { diff --git a/libs/shared/interfaces/src/lib/recording-metadata.interface.ts b/libs/shared/interfaces/src/lib/recording-metadata.interface.ts new file mode 100644 index 000000000..943186d2f --- /dev/null +++ b/libs/shared/interfaces/src/lib/recording-metadata.interface.ts @@ -0,0 +1,82 @@ +/** + * Metadata captured for a live-TV recording. + * + * EPG is time-sensitive: what is airing on a channel cannot be reconstructed + * after the fact (Xtream/Stalker EPG never reaches SQLite), so the renderer + * snapshots everything it knows at recording START and hands it down with the + * start IPC. When the recording stops cleanly, the renderer additionally + * filters its in-memory program list to the programs overlapping the recorded + * interval — that is how a recording spanning a program boundary lists every + * covered program. An implicit stop (session dispose, crash) keeps the start + * snapshot as-is. + */ + +export interface RecordingProgramSnapshot { + title: string; + description?: string; + /** ISO datetime string, same convention as `epg_programs.start`. */ + start: string; + /** ISO datetime string, same convention as `epg_programs.stop`. */ + stop: string; +} + +export type RecordingSourceType = 'm3u' | 'xtream' | 'stalker'; + +export interface RecordingStartMetadata { + channelName: string; + channelLogoUrl?: string; + playlistId?: string; + /** Display label snapshot — recordings outlive playlist deletion. */ + playlistName?: string; + sourceType?: RecordingSourceType; + /** Resolved EPG lookup/mapping key, when the host knows one. */ + epgChannelId?: string; + /** + * Host-scoped identity of the exact recorded selection. The EPG key is + * not unique for M3U items (`tvgId`, or the display-name fallback, can + * be shared by several list entries), so stop enrichment additionally + * compares this key — otherwise switching between two same-keyed items + * while recording would attach the second item's schedule to the first + * item's recording. Opaque to everything below the host. + */ + sourceItemKey?: string; + /** The program airing when the recording started, if EPG knew it. */ + currentProgram?: RecordingProgramSnapshot; +} + +/** + * Emitted by the player layer when a recording stopped cleanly. The host + * component answers it with the programs overlapping [startedAt, endedAt] + * from its in-memory EPG state (stop enrichment). Implicit stops (session + * dispose, crash) never emit — the start snapshot stands. + */ +export interface RecordingStoppedEvent { + targetPath: string; + /** Recording start (ISO); null when the snapshot never carried it. */ + startedAt: string | null; + /** Observation time of the stop (ISO). */ + endedAt: string; + /** + * EPG key of the channel that was being recorded, captured when the + * recording went active. Switching channels auto-stops the recording, and + * by the time the host handles the stop its own state already describes + * the NEW channel — the host compares this key before enriching, so a + * recording is never given another channel's schedule. + */ + epgChannelId?: string | null; + /** + * `RecordingStartMetadata.sourceItemKey` captured the same way. The EPG + * key alone cannot tell two same-keyed M3U items apart, so hosts that + * set a source-item key also compare it before enriching. + */ + sourceItemKey?: string | null; +} + +export const RECORDING_STATUSES = [ + 'recording', + 'completed', + 'interrupted', + 'failed', +] as const; + +export type RecordingStatus = (typeof RECORDING_STATUSES)[number]; diff --git a/libs/shared/interfaces/src/lib/recording-program-overlap.util.spec.ts b/libs/shared/interfaces/src/lib/recording-program-overlap.util.spec.ts new file mode 100644 index 000000000..47b1546d8 --- /dev/null +++ b/libs/shared/interfaces/src/lib/recording-program-overlap.util.spec.ts @@ -0,0 +1,115 @@ +import { + filterRecordingProgramsOverlap, + toRecordingProgramSnapshot, +} from './recording-program-overlap.util'; + +const program = (title: string, start: string, stop: string) => ({ + title, + start, + stop, +}); + +describe('filterRecordingProgramsOverlap', () => { + const programs = [ + program('Late show', '2026-08-15T22:10:00Z', '2026-08-15T23:00:00Z'), + program('News', '2026-08-15T21:00:00Z', '2026-08-15T21:45:00Z'), + program('Weather', '2026-08-15T21:45:00Z', '2026-08-15T22:10:00Z'), + program('Morning', '2026-08-15T08:00:00Z', '2026-08-15T09:00:00Z'), + ]; + + it('fails closed without a recording start', () => { + expect( + filterRecordingProgramsOverlap( + programs, + null, + '2026-08-15T22:00:00Z' + ) + ).toEqual([]); + }); + + it('fails closed on unparseable or inverted windows', () => { + expect( + filterRecordingProgramsOverlap( + programs, + 'garbage', + '2026-08-15T22:00:00Z' + ) + ).toEqual([]); + expect( + filterRecordingProgramsOverlap( + programs, + '2026-08-15T22:00:00Z', + '2026-08-15T21:00:00Z' + ) + ).toEqual([]); + }); + + it('keeps every overlapping program sorted by start (boundary case)', () => { + const result = filterRecordingProgramsOverlap( + programs, + '2026-08-15T21:30:00Z', + '2026-08-15T21:58:00Z' + ); + expect(result.map(({ title }) => title)).toEqual(['News', 'Weather']); + }); + + it('excludes programs that only touch the window edges', () => { + // Weather starts exactly at window end; News stops exactly at start. + const result = filterRecordingProgramsOverlap( + programs, + '2026-08-15T21:45:00Z', + '2026-08-15T21:45:00Z' + ); + expect(result).toEqual([]); + }); + + it('drops programs with unparseable times', () => { + const result = filterRecordingProgramsOverlap( + [program('Broken', 'nope', 'also nope'), programs[1]], + '2026-08-15T21:10:00Z', + '2026-08-15T21:20:00Z' + ); + expect(result.map(({ title }) => title)).toEqual(['News']); + }); +}); + +describe('toRecordingProgramSnapshot', () => { + it('prefers description over desc and trims it', () => { + expect( + toRecordingProgramSnapshot({ + title: 'News', + description: ' Daily ', + desc: 'legacy', + start: 'a', + stop: 'b', + }) + ).toEqual({ title: 'News', description: 'Daily', start: 'a', stop: 'b' }); + }); + + it('falls back to the XMLTV desc field', () => { + expect( + toRecordingProgramSnapshot({ + title: 'News', + desc: 'From XMLTV', + start: 'a', + stop: 'b', + }) + ).toEqual({ + title: 'News', + description: 'From XMLTV', + start: 'a', + stop: 'b', + }); + }); + + it('omits description entirely when both sources are empty', () => { + expect( + toRecordingProgramSnapshot({ + title: 'News', + desc: null, + start: 'a', + stop: 'b', + }) + ).toEqual({ title: 'News', start: 'a', stop: 'b' }); + }); +}); diff --git a/libs/shared/interfaces/src/lib/recording-program-overlap.util.ts b/libs/shared/interfaces/src/lib/recording-program-overlap.util.ts new file mode 100644 index 000000000..f6912800e --- /dev/null +++ b/libs/shared/interfaces/src/lib/recording-program-overlap.util.ts @@ -0,0 +1,63 @@ +import type { RecordingProgramSnapshot } from './recording-metadata.interface'; + +/** + * Normalizes the hosts' program shapes (XMLTV `EpgProgram.desc`, Xtream + * `EpgItem.description`) into the recording snapshot form. + */ +export function toRecordingProgramSnapshot(program: { + title: string; + desc?: string | null; + description?: string | null; + start: string; + stop: string; +}): RecordingProgramSnapshot { + const description = (program.description ?? program.desc ?? '').trim(); + return { + title: program.title, + ...(description ? { description } : {}), + start: program.start, + stop: program.stop, + }; +} + +/** + * Filters normalized program snapshots to the ones overlapping the recorded + * interval, sorted by start. Used by the live hosts' stop-enrichment + * handlers, so a recording spanning a program boundary reports every covered + * program. Fails closed: an unknown recording start yields an empty list + * (the start snapshot then stands). + */ +export function filterRecordingProgramsOverlap( + programs: readonly RecordingProgramSnapshot[], + startedAt: string | null, + endedAt: string +): RecordingProgramSnapshot[] { + if (!startedAt) { + return []; + } + const windowStart = Date.parse(startedAt); + const windowEnd = Date.parse(endedAt); + if ( + !Number.isFinite(windowStart) || + !Number.isFinite(windowEnd) || + windowEnd <= windowStart + ) { + return []; + } + + return programs + .map((program) => ({ + program, + start: Date.parse(program.start), + stop: Date.parse(program.stop), + })) + .filter( + ({ start, stop }) => + Number.isFinite(start) && + Number.isFinite(stop) && + start < windowEnd && + stop > windowStart + ) + .sort((a, b) => a.start - b.start) + .map(({ program }) => program); +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts index c79a09064..84861131e 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts @@ -259,6 +259,19 @@ describe('EmbeddedMpvCommandRunner', () => { expect(session()?.recording?.active).toBe(true); }); + it('startRecording forwards the channel metadata snapshot in the options', async () => { + const metadata = { + channelName: 'Channel One', + playlistId: 'playlist-a', + sourceType: 'xtream' as const, + }; + await runner.startRecording('/custom', 'My Show', metadata); + expect(electron.startEmbeddedMpvRecording).toHaveBeenCalledWith( + 'mpv-1', + { directory: '/custom', title: 'My Show', metadata } + ); + }); + it('does not start recording after the session changes during folder lookup', async () => { let resolveFolder: ((folder: string) => void) | null = null; electron.getEmbeddedMpvDefaultRecordingFolder.mockImplementationOnce( diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.ts index 06692fc43..2239ebc0f 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.ts @@ -1,5 +1,8 @@ import { Signal, WritableSignal } from '@angular/core'; -import { EmbeddedMpvSession } from '@iptvnator/shared/interfaces'; +import { + EmbeddedMpvSession, + RecordingStartMetadata, +} from '@iptvnator/shared/interfaces'; type ElectronBridge = Window['electron']; @@ -107,7 +110,8 @@ export class EmbeddedMpvCommandRunner { async startRecording( directory: string | undefined, - title: string + title: string, + metadata?: RecordingStartMetadata ): Promise { const id = this.ctx.sessionId(); const electron = this.bridge(); @@ -126,6 +130,7 @@ export class EmbeddedMpvCommandRunner { startEmbeddedMpvRecording(id, { directory: resolvedDirectory, title, + ...(metadata ? { metadata } : {}), }) ); return updated?.recording ?? null; diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts index 8f3f57782..19cada4dd 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts @@ -1,5 +1,6 @@ import type { EmbeddedMpvSession, + RecordingStartMetadata, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { @@ -44,6 +45,8 @@ export interface RecordingToggleContext { readonly playback: ResolvedPortalPlayback; readonly playbackIdentity: string; readonly session: EmbeddedMpvSession; + /** Channel/EPG snapshot forwarded to the main-process recording tracker. */ + readonly metadata?: RecordingStartMetadata | null; } export class EmbeddedMpvControlsRecording { @@ -94,7 +97,8 @@ export class EmbeddedMpvControlsRecording { kind === RECORDING_OPERATION.START ? this.controller.startRecording( context.folder, - context.playback.title + context.playback.title, + context.metadata ?? undefined ) : this.controller.stopRecording(); void command.then( diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.messages.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.messages.spec.ts index da14adcbc..4f04723d6 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.messages.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.messages.spec.ts @@ -248,6 +248,35 @@ describe('EmbeddedMpvControlsAdapter recording messages and lifecycle', () => { expect(adapter.state().audioTracks[0].label).toBe('DE Audio 1'); }); + it('forwards the host recording metadata into the start command', async () => { + const metadata = { + channelName: 'Channel One', + playlistId: 'playlist-a', + sourceType: 'm3u' as const, + currentProgram: { + title: 'Evening News', + start: '2026-07-16T10:00:00.000Z', + stop: '2026-07-16T11:00:00.000Z', + }, + }; + adapter.configure({ + playback, + seriesNavigation, + recordingFolder, + recordingMetadata: signal(metadata), + }); + TestBed.tick(); + + adapter.commands.toggleRecording(); + await flushPromises(); + + expect(controller.startRecording).toHaveBeenCalledWith( + '/recordings', + LIVE_PLAYBACK.title, + metadata + ); + }); + it('relocalizes saved feedback for every translate event source', async () => { const targetPath = '/recordings/live.ts'; controller.session.set( diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.races.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.races.spec.ts index e71a15118..05d18ab83 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.races.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.races.spec.ts @@ -185,7 +185,9 @@ describe('EmbeddedMpvControlsAdapter recording acknowledgement races', () => { expect(controller.startRecording).toHaveBeenCalledWith( '/recordings', - LIVE_PLAYBACK.title + LIVE_PLAYBACK.title, + // No host recording metadata configured in this harness. + undefined ); expect(adapter.state().recording.message).toBeNull(); controller.session.update((current) => ({ diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts index 742d19e77..7dd4ad750 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts @@ -12,6 +12,8 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { EmbeddedMpvSession, EmbeddedMpvSupport, + RecordingStartMetadata, + RecordingStoppedEvent, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { TranslateService } from '@ngx-translate/core'; @@ -43,6 +45,8 @@ export interface EmbeddedMpvControlsContext { readonly playback: Signal; readonly seriesNavigation: Signal; readonly recordingFolder: Signal; + /** Channel/EPG snapshot the live host captured for the recording tracker. */ + readonly recordingMetadata?: Signal; } interface MappedPlayerStatus { @@ -349,6 +353,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { playback: context.playback(), playbackIdentity, session, + metadata: context.recordingMetadata?.() ?? null, }); } } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts index dd0b54223..bb68b0b24 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts @@ -380,4 +380,133 @@ describe('EmbeddedMpvPlayerComponent shared controls host', () => { expect(startRecording).not.toHaveBeenCalled(); }); + + it('emits recordingStopped once when an active recording goes inactive', () => { + const { fixture, component, controller } = render(); + const stopped: Array<{ + targetPath: string; + startedAt: string | null; + }> = []; + component.recordingStopped.subscribe((event) => + stopped.push({ + targetPath: event.targetPath, + startedAt: event.startedAt, + }) + ); + + controller.session.set({ + ...READY_SESSION, + recording: { + active: true, + targetPath: '/rec/news.ts', + startedAt: '2026-07-16T21:00:00Z', + }, + }); + fixture.detectChanges(); + expect(stopped).toHaveLength(0); + + // Stopping from the download manager talks to the main process + // directly, so the only signal this component sees is the snapshot + // flipping inactive — it must still drive stop enrichment. + controller.session.set({ + ...READY_SESSION, + recording: { active: false, targetPath: '/rec/news.ts' }, + }); + fixture.detectChanges(); + + expect(stopped).toEqual([ + { targetPath: '/rec/news.ts', startedAt: '2026-07-16T21:00:00Z' }, + ]); + + // Later inactive snapshots are not another stop. + controller.session.set({ + ...READY_SESSION, + positionSeconds: 44, + recording: { active: false, targetPath: '/rec/news.ts' }, + }); + fixture.detectChanges(); + expect(stopped).toHaveLength(1); + }); + + it('carries the recorded channel identity through the stop event', () => { + const { fixture, component, controller } = render(); + fixture.componentRef.setInput('recordingMetadata', { + channelName: 'Channel One', + epgChannelId: 'channel.one', + }); + const stopped: Array = []; + component.recordingStopped.subscribe((event) => + stopped.push(event.epgChannelId) + ); + + controller.session.set({ + ...READY_SESSION, + recording: { active: true, targetPath: '/rec/news.ts' }, + }); + fixture.detectChanges(); + + // A channel switch auto-stops the recording and moves the host on; + // the event must still name the channel that was recorded. + fixture.componentRef.setInput('recordingMetadata', { + channelName: 'Channel Two', + epgChannelId: 'channel.two', + }); + controller.session.set({ + ...READY_SESSION, + recording: { active: false, targetPath: '/rec/news.ts' }, + }); + fixture.detectChanges(); + + expect(stopped).toEqual(['channel.one']); + }); + + it('carries the recorded source-item key through the stop event', () => { + // The EPG key is not unique for M3U items (shared tvgId or the + // name fallback): two list entries can share it, so the exact + // selection identity must survive the switch the same way. + const { fixture, component, controller } = render(); + fixture.componentRef.setInput('recordingMetadata', { + channelName: 'News HD', + epgChannelId: 'news.hd', + sourceItemKey: 'item-1', + }); + const stopped: Array = []; + component.recordingStopped.subscribe((event) => + stopped.push(event.sourceItemKey) + ); + + controller.session.set({ + ...READY_SESSION, + recording: { active: true, targetPath: '/rec/news.ts' }, + }); + fixture.detectChanges(); + + // Same EPG key, different list item — the uid must not follow. + fixture.componentRef.setInput('recordingMetadata', { + channelName: 'News HD', + epgChannelId: 'news.hd', + sourceItemKey: 'item-2', + }); + controller.session.set({ + ...READY_SESSION, + recording: { active: false, targetPath: '/rec/news.ts' }, + }); + fixture.detectChanges(); + + expect(stopped).toEqual(['item-1']); + }); + + it('does not emit recordingStopped without a preceding active recording', () => { + const { fixture, component, controller } = render(); + const stopped: unknown[] = []; + component.recordingStopped.subscribe((event) => stopped.push(event)); + + controller.session.set({ + ...READY_SESSION, + recording: { active: false, targetPath: '/rec/stale.ts' }, + }); + fixture.detectChanges(); + + expect(stopped).toHaveLength(0); + }); }); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index d22b261cc..fa450f17c 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -21,6 +21,8 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { merge } from 'rxjs'; import { EmbeddedMpvAudioTrack, + RecordingStartMetadata, + RecordingStoppedEvent, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { PlayerControlsComponent } from '../player-controls/player-controls.component'; @@ -77,6 +79,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { readonly playback = input.required(); readonly showControls = input(true); readonly recordingFolder = input(''); + readonly recordingMetadata = input(null); readonly seriesNavigation = input(null); readonly mediaTitle = input(null); @@ -87,6 +90,8 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); + /** Clean recording stop — the host answers with EPG stop enrichment. */ + readonly recordingStopped = output(); private readonly overlayVisibility = inject( EmbeddedMpvOverlayVisibilityService @@ -372,6 +377,54 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { playback: this.playback, seriesNavigation: this.seriesNavigation, recordingFolder: this.recordingFolder, + recordingMetadata: this.recordingMetadata, + }); + + // Single owner of the "a recording just stopped" edge, for every + // engine and every trigger — the controls button, the download + // manager's Stop (which talks to the main process directly and never + // reaches this component's own toggle), or mpv stopping on its own. + // The host answers with EPG stop enrichment. + let previousRecording: { + targetPath: string; + startedAt: string | null; + epgChannelId: string | null; + sourceItemKey: string | null; + } | null = null; + effect(() => { + const recording = this.controller.session()?.recording; + untracked(() => { + if (recording?.active && recording.targetPath) { + previousRecording = { + targetPath: recording.targetPath, + startedAt: recording.startedAt ?? null, + // Captured while it is still the recorded channel: a + // channel switch auto-stops the recording and moves + // the host's state on before the stop is handled. + epgChannelId: + previousRecording?.epgChannelId ?? + this.recordingMetadata()?.epgChannelId ?? + null, + sourceItemKey: + previousRecording?.sourceItemKey ?? + this.recordingMetadata()?.sourceItemKey ?? + null, + }; + return; + } + if (!previousRecording) { + return; + } + const stopped = previousRecording; + previousRecording = null; + this.recordingStopped.emit({ + targetPath: stopped.targetPath, + startedAt: stopped.startedAt, + endedAt: new Date().toISOString(), + epgChannelId: stopped.epgChannelId, + sourceItemKey: stopped.sourceItemKey, + }); + }); }); if (typeof document !== 'undefined') { @@ -685,6 +738,8 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { if (this.isRecording()) { const recording = await this.controller.stopRecording(); if (recording?.targetPath) { + // recordingStopped is emitted by the session watcher in the + // constructor, which also covers stops triggered elsewhere. this.setRecordingMessage( this.translate.instant('EMBEDDED_MPV.PLAYER.SAVED_TO', { path: recording.targetPath, @@ -718,7 +773,8 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { this.setRecordingMessage(null); const recording = await this.controller.startRecording( this.recordingFolder(), - this.playback().title + this.playback().title, + this.recordingMetadata() ?? undefined ); if (recording?.active) { this.feedback.flash( diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts index f760bec97..ecb54e379 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts @@ -11,6 +11,7 @@ import { EmbeddedMpvBounds, EmbeddedMpvSession, EmbeddedMpvSupport, + RecordingStartMetadata, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { EmbeddedMpvCommandRunner } from './embedded-mpv-command-runner'; @@ -306,9 +307,10 @@ export class EmbeddedMpvSessionController { this.commands.setAspect(aspect); readonly startRecording = ( directory: string | undefined, - title: string + title: string, + metadata?: RecordingStartMetadata ): Promise => - this.commands.startRecording(directory, title); + this.commands.startRecording(directory, title, metadata); readonly stopRecording = (): Promise< EmbeddedMpvSession['recording'] | null > => this.commands.stopRecording(); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index bb1cf277d..bf67c6a99 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -86,11 +86,13 @@ [playback]="resolvedPlayback()" [mediaTitle]="resolvedMediaTitle()" [recordingFolder]="recordingFolder()" + [recordingMetadata]="recordingMetadata()" [seriesNavigation]="seriesNavigation()" (timeUpdate)="handleTimeUpdate($event, application)" (playbackEnded)="playbackEnded.emit()" (previousEpisodeRequested)="previousEpisodeRequested.emit()" (nextEpisodeRequested)="nextEpisodeRequested.emit()" + (recordingStopped)="recordingStopped.emit($event)" /> } } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index f42224023..9bb8f2684 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -13,7 +13,11 @@ import { VodSourceRowComponent } from '@iptvnator/ui/components'; import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; -import { VideoPlayer } from '@iptvnator/shared/interfaces'; +import { + VideoPlayer, + type RecordingStartMetadata, + type RecordingStoppedEvent, +} from '@iptvnator/shared/interfaces'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { ErrorDetails, ErrorTypes } from 'hls.js'; import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component'; @@ -106,11 +110,13 @@ class StubEmbeddedMpvPlayerComponent { readonly playback = input.required(); readonly mediaTitle = input(null); readonly recordingFolder = input(''); + readonly recordingMetadata = input(null); readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); + readonly recordingStopped = output(); } describe('WebPlayerViewComponent', () => { diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index f0be45979..9dffa5654 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -22,6 +22,8 @@ import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { VideoPlayer, type Channel, + type RecordingStartMetadata, + type RecordingStoppedEvent, type ResolvedPortalPlayback, type VodSourceDescriptor, } from '@iptvnator/shared/interfaces'; @@ -110,6 +112,8 @@ export class WebPlayerViewComponent implements OnDestroy { readonly seriesNavigation = input(null); readonly mediaTitle = input(null); readonly alternativeSources = input([]); + /** Channel/EPG snapshot for the embedded-MPV recording tracker. */ + readonly recordingMetadata = input(null); readonly timeUpdate = output<{ currentTime: number; @@ -122,6 +126,8 @@ export class WebPlayerViewComponent implements OnDestroy { readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); + /** Re-emitted embedded-MPV clean recording stop (stop enrichment). */ + readonly recordingStopped = output(); readonly showCaptions = computed( () => this.settingsStore.showCaptions?.() ?? false diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts index bc58931ba..1783cb8af 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts @@ -1,5 +1,9 @@ import { Component, input, output } from '@angular/core'; import type { PlaybackDiagnostic } from '@iptvnator/playback/util'; +import type { + RecordingStartMetadata, + RecordingStoppedEvent, +} from '@iptvnator/shared/interfaces'; /** * Player stand-ins for WebPlayerViewComponent specs. They mirror the real @@ -74,9 +78,11 @@ export class StubEmbeddedMpvPlayerComponent { readonly playback = input.required(); readonly mediaTitle = input(null); readonly recordingFolder = input(''); + readonly recordingMetadata = input(null); readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); + readonly recordingStopped = output(); } diff --git a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts index 6f36c3f9d..53b05e4a0 100644 --- a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts +++ b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts @@ -76,6 +76,30 @@ describe('workspace-shell-route.utils', () => { }) ); + expect( + parseWorkspaceShellRoute('/workspace/downloads/recording/7') + ).toEqual( + expect.objectContaining({ + kind: 'downloads', + context: null, + section: null, + contextPanel: 'none', + searchMode: 'none', + usesQuerySearch: false, + }) + ); + + // The bare recording segment without an id is NOT a focused detail. + expect( + parseWorkspaceShellRoute('/workspace/downloads/recording') + ).toEqual( + expect.objectContaining({ + kind: 'downloads', + searchMode: 'local-filter', + usesQuerySearch: true, + }) + ); + expect( parseWorkspaceShellRoute('/workspace/xtreams/pl-1/downloads/42') ).toEqual( diff --git a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts index 68239ca2a..c5232ae43 100644 --- a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts +++ b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts @@ -303,8 +303,17 @@ export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute { : page === 'downloads' ? 'downloads' : 'unknown'; + // Focused detail pages hide the context panel and route search: + // /workspace/downloads/:downloadId (3 segments) and the recording detail + // /workspace/downloads/recording/:recordingId (4 segments). const isFocusedDownloadDetail = - kind === 'downloads' && segments.length === 3 && Boolean(segments[2]); + kind === 'downloads' && + ((segments.length === 3 && + segments[2] !== 'recording' && + Boolean(segments[2])) || + (segments.length === 4 && + segments[2] === 'recording' && + Boolean(segments[3]))); const searchMode = isFocusedDownloadDetail ? 'none' : resolveRouteSearchMode(kind, null, null); From 72727a5dfafc6eccb809342fb99299c619a0a6d8 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:06:46 +0200 Subject: [PATCH 8/8] feat(dashboard): detail-first Continue Watching cards with quick actions (#1469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): detail-first continue watching cards with quick actions (#1441) Continue Watching cards now open the detail page on click like movie cards; resuming the saved episode, marking it watched, and removing the entry from history move into a per-card ⋮ menu. Series details land on the earliest season with unwatched episodes (or the latest once all are watched) instead of always season 1. Co-Authored-By: Claude Fable 5 * fix(dashboard): address review findings and season auto-select regressions - A session's own watched toggles no longer re-resolve the selected season when the positions map first fills — marking season 1 watched used to jump the view to season 2 (CI regression in the web and Electron season-watched-toggle E2Es). - The all-watched season fallback skips loaded-but-empty seasons and picks the latest season that has episodes (Greptile P1). - Mark as Watched uses the strict failure-propagating save boundary (Codex P2), and both card mutations surface persistence failures via a snackbar with the new WORKSPACE.DASHBOARD.ACTION_FAILED key in all 19 languages (Greptile P2). - Season E2Es now assert the intended post-reload behavior: the fresh mount lands on the earliest unwatched season while season 1 keeps its watched state behind its tab. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: 4gray Co-authored-by: Claude Fable 5 --- .../dashboard-continue-watching-actions.md | 10 + .changes/portals-season-auto-select.md | 9 + CLAUDE.md | 4 +- .../src/season-watched-toggle.e2e.ts | 26 ++- apps/web-e2e/src/xtream.e2e.ts | 15 +- apps/web/src/assets/i18n/ar.json | 1 + apps/web/src/assets/i18n/ary.json | 1 + apps/web/src/assets/i18n/by.json | 1 + apps/web/src/assets/i18n/de.json | 1 + apps/web/src/assets/i18n/el.json | 1 + apps/web/src/assets/i18n/en.json | 1 + apps/web/src/assets/i18n/es.json | 1 + apps/web/src/assets/i18n/fr.json | 1 + apps/web/src/assets/i18n/hu.json | 1 + apps/web/src/assets/i18n/it.json | 1 + apps/web/src/assets/i18n/ja.json | 1 + apps/web/src/assets/i18n/ko.json | 1 + apps/web/src/assets/i18n/nl.json | 1 + apps/web/src/assets/i18n/pl.json | 1 + apps/web/src/assets/i18n/pt.json | 1 + apps/web/src/assets/i18n/ru.json | 1 + apps/web/src/assets/i18n/tr.json | 1 + apps/web/src/assets/i18n/zh.json | 1 + apps/web/src/assets/i18n/zhtw.json | 1 + docs/architecture/embedded-inline-playback.md | 7 +- docs/architecture/portal-detail-navigation.md | 15 +- .../navigation/workspace-portal-navigation.ts | 14 +- .../season-container.auto-season.spec.ts | 208 ++++++++++++++++++ .../season-container.component.ts | 74 ++++++- .../src/lib/dashboard-data.service.ts | 48 ++++ .../src/lib/dashboard-navigation.util.spec.ts | 72 +++++- .../src/lib/dashboard-navigation.util.ts | 63 +++++- .../src/lib/rails/dashboard-rail.utils.ts | 44 ++++ .../workspace-dashboard-rails.component.html | 1 + ...orkspace-dashboard-rails.component.spec.ts | 46 ++++ .../workspace-dashboard-rails.component.ts | 91 +++++++- 36 files changed, 722 insertions(+), 44 deletions(-) create mode 100644 .changes/dashboard-continue-watching-actions.md create mode 100644 .changes/portals-season-auto-select.md create mode 100644 libs/ui/components/src/lib/season-container/season-container.auto-season.spec.ts diff --git a/.changes/dashboard-continue-watching-actions.md b/.changes/dashboard-continue-watching-actions.md new file mode 100644 index 000000000..edf1a0ced --- /dev/null +++ b/.changes/dashboard-continue-watching-actions.md @@ -0,0 +1,10 @@ +--- +type: feature +area: dashboard +issues: [1441] +--- + +Continue Watching cards on the dashboard now open the detail page on click, +just like movie cards — no more accidental episode playback. Resuming the +saved episode moved to a new ⋮ menu on each card, next to "Mark as Watched" +and "Remove from history". diff --git a/.changes/portals-season-auto-select.md b/.changes/portals-season-auto-select.md new file mode 100644 index 000000000..8fe40b3ce --- /dev/null +++ b/.changes/portals-season-auto-select.md @@ -0,0 +1,9 @@ +--- +type: feature +area: portals +issues: [1441] +--- + +Opening a series now lands on the season you'd actually continue: the +earliest season with unwatched episodes, or the latest season once +everything is watched — instead of always starting at season 1. diff --git a/CLAUDE.md b/CLAUDE.md index 6084f1fdc..4b9065f74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1354,9 +1354,9 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the - A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort. - Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items; `is_series` is normalized only from `true`, `1`, or `'1'`. Quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. Lazy VOD episode tracking IDs scope the parent series, provider episode, season key, and episode number; the previous season/episode hash is only a compatibility alias. Exact scoped positions win, while compatible legacy rows are considered only for the current parent and must match any stored season/episode coordinates. The scoped row is persisted through the strict failure-propagating boundary before confirmed legacy cleanup, so a failed save keeps the old row; compatibility is lazy and performs no schema migration or bulk rewrite. - Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block -- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker) +- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → earliest season with unwatched episodes → latest non-empty season; Stalker lazy-VOD series with unhydrated seasons fall back to the first season, and a session's own watched-toggle echo never re-resolves the selection) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker) - The season header hosts a bulk watched toggle next to "Download season" (both portals): marking writes full-progress position rows for the unwatched episodes only — skipping the episode currently playing/launching, whose position ticks would overwrite the row — and a fully watched season flips the action to unwatch-all (`buildSeasonWatchToggleRequest` in `libs/ui/components/.../season-watch-toggle.util.ts`). Xtream persists via the batch IPC `DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH` (one SQLite transaction; the PWA data source rewrites its localStorage blob once) and refreshes `XtreamStore.loadAllPositions` after any toggle so catalog progress badges follow; Stalker loops the serialized position-mutation queue (legacy-row reconciliation, one coalesced reload) and reports direction-specific partial failures. A batch resolving after navigation neither mutates the new page's state nor shows its snackbar. A series-level counterpart sits in a `⋮` menu at the end of the header row (`SeasonWatchPresenter` owns both scopes' state math; `buildSeriesWatchToggleRequest` flattens every loaded season; the direction is always the one the label advertised). It reuses the same host machinery per portal (Xtream: scope-parameterized `SerialDetailsSeasonWatchService`; Stalker: shared `runWatchToggleBatch` core). Stalker lazy-VOD hydrates unloaded seasons sequentially first (abort with zero writes on a failed fetch; a well-formed EMPTY portal answer marks the season loaded-and-empty via `VodSeriesSeasonVm.episodesLoaded` rather than eternally pending, while `fetchVodSeriesEpisodes` rejects malformed envelopes and answers without recognizable episodes; `loadEpisodesForSeason` is single-flight per season so concurrent callers join one request), re-runs the position reconcile synchronously so hydrated episodes' legacy rows are cleaned, then rebuilds the request keeping the clicked direction — the `hasUnloadedSeasons` container input blocks the unwatch verdict and the count label until everything is loaded. Contract: `docs/architecture/embedded-inline-playback.md` -- Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only. +- The dashboard hero CTA and the Continue Watching cards' explicit "Resume episode" ⋮ action for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Continue Watching cards' DEFAULT click is detail-only (movie-like, issue #1441), and their ⋮ menu (`buildDashboardContinueWatchingActions`) also offers "Mark as Watched" (maxes out the existing position row via `DashboardDataService.markRecentItemWatched`) and "Remove from history". Ordinary global-recent grid clicks remain detail-only. - See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout") **VOD Multi-Source** (alternative sources for a movie): diff --git a/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts b/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts index 301449f70..1bdf300d7 100644 --- a/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts +++ b/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts @@ -223,20 +223,32 @@ test.describe('Electron Season Watched Toggle', () => { const restartedWatchedCards = app.mainWindow.locator( '.episode-card--watched' ); + // With season 1 completed, the fresh mount auto-selects the + // earliest season with unwatched episodes (issue #1441): season 2 + // opens unwatched while season 1's tab keeps the check. await expect(restartedToggle).toBeVisible({ timeout: 20_000 }); await expect(restartedToggle).toContainText( - 'Mark season as unwatched' + `Mark season as watched (${seasonEpisodeCount})` + ); + await expect(restartedWatchedCards).toHaveCount(0, { + timeout: 10_000, + }); + const restartedSeasonTabs = + app.mainWindow.locator('.season-tabs__pill'); + await expect( + restartedSeasonTabs.first().locator('.season-tabs__done') + ).toBeVisible(); + + // Season 1 itself was re-read from SQLite fully watched. + await restartedSeasonTabs.first().click(); + await expect(restartedToggle).toContainText( + 'Mark season as unwatched', + { timeout: 15_000 } ); await expect(restartedWatchedCards).toHaveCount( seasonEpisodeCount, { timeout: 10_000 } ); - await expect( - app.mainWindow - .locator('.season-tabs__pill') - .first() - .locator('.season-tabs__done') - ).toBeVisible(); // Unmark: the clear batch removes every row again, in the DOM and // in SQLite. diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 8df41987a..fa61707a0 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -754,15 +754,24 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle ); // PWA persistence: positions live in localStorage, so a reload of the - // detail route must come back fully watched. + // detail route must come back fully watched. With season 1 completed the + // fresh mount auto-selects the earliest season with unwatched episodes + // (issue #1441), so season 2 opens while season 1's tab keeps the check. await page.reload(); await expect(seasonToggle).toBeVisible({ timeout: 20_000 }); - await expect(seasonToggle).toContainText('Mark season as unwatched'); - await expect(watchedCards).toHaveCount(8, { timeout: 10_000 }); + await expect(seasonToggle).toContainText('Mark season as watched (8)'); + await expect(watchedCards).toHaveCount(0, { timeout: 10_000 }); await expect( seasonTabs.first().locator('.season-tabs__done') ).toBeVisible(); + // Season 1 itself still holds the 8 persisted rows. + await seasonTabs.first().click(); + await expect(seasonToggle).toContainText('Mark season as unwatched', { + timeout: 15_000, + }); + await expect(watchedCards).toHaveCount(8, { timeout: 10_000 }); + // Second click clears every episode's position again. await seasonToggle.click(); await expect(seasonToggle).toContainText('Mark season as watched (8)', { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 96dd79bc0..02e1a6c58 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "ترتيب الخادم", "CATEGORY_SORT_ARIA": "ترتيب الفئات", "DASHBOARD": { + "ACTION_FAILED": "تعذر إكمال الإجراء. يرجى المحاولة مرة أخرى.", "CONTINUE_WATCHING": "متابعة المشاهدة", "LIVE_FAVORITES": "يُبث الآن على المفضلة لديك", "LIVE_RECENT": "متابعة البث المباشر", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index c19d29267..bbe6bf7b1 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "ترتيب السيرفر", "CATEGORY_SORT_ARIA": "ترتيب التصنيفات", "DASHBOARD": { + "ACTION_FAILED": "ما قدرناش نكملو العملية. عاود حاول مرة أخرى.", "CONTINUE_WATCHING": "كمل المشاهدة", "LIVE_FAVORITES": "مباشر دابا على المفضلة ديالك", "LIVE_RECENT": "كمل مع المباشر", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index a16a2fb97..c1740f917 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Парадак сервера", "CATEGORY_SORT_ARIA": "Сартаваць катэгорыі", "DASHBOARD": { + "ACTION_FAILED": "Не атрымалася выканаць дзеянне. Паспрабуйце яшчэ раз.", "CONTINUE_WATCHING": "Працягнуць прагляд", "LIVE_FAVORITES": "Цяпер у эфіры на выбраных", "LIVE_RECENT": "Працягнуць прагляд эфіру", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 85faf5100..e75bb72fb 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Serverreihenfolge", "CATEGORY_SORT_ARIA": "Kategorien sortieren", "DASHBOARD": { + "ACTION_FAILED": "Die Aktion konnte nicht abgeschlossen werden. Bitte erneut versuchen.", "CONTINUE_WATCHING": "Weiter schauen", "LIVE_FAVORITES": "Jetzt live auf Ihren Favoriten", "LIVE_RECENT": "Mit Live-TV weiterschauen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 55c8f33f3..dfe0bcf6e 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Σειρά διακομιστή", "CATEGORY_SORT_ARIA": "Ταξινόμηση κατηγοριών", "DASHBOARD": { + "ACTION_FAILED": "Δεν ήταν δυνατή η ολοκλήρωση της ενέργειας. Δοκιμάστε ξανά.", "CONTINUE_WATCHING": "Συνέχιση παρακολούθησης", "LIVE_FAVORITES": "Ζωντανά τώρα στα αγαπημένα σας", "LIVE_RECENT": "Συνέχιση με ζωντανή TV", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 4caeb987a..28b8a5727 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Server sorting", "CATEGORY_SORT_ARIA": "Sort categories", "DASHBOARD": { + "ACTION_FAILED": "The action could not be completed. Please try again.", "CONTINUE_WATCHING": "Continue Watching", "LIVE_FAVORITES": "Live now on your favorites", "LIVE_RECENT": "Continue with live TV", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 0124fc1e1..0b4dd72ad 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Orden del servidor", "CATEGORY_SORT_ARIA": "Ordenar categorías", "DASHBOARD": { + "ACTION_FAILED": "No se pudo completar la acción. Inténtalo de nuevo.", "CONTINUE_WATCHING": "Continuar viendo", "LIVE_FAVORITES": "Ahora en vivo en tus favoritos", "LIVE_RECENT": "Continuar con TV en vivo", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 92db1dffa..062879718 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Ordre du serveur", "CATEGORY_SORT_ARIA": "Trier les catégories", "DASHBOARD": { + "ACTION_FAILED": "L'action n'a pas pu être effectuée. Veuillez réessayer.", "CONTINUE_WATCHING": "Reprendre la lecture", "LIVE_FAVORITES": "En direct sur vos favoris", "LIVE_RECENT": "Reprendre la TV en direct", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 33094adae..047f2bcf8 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Kiszolgálóoldali rendezés", "CATEGORY_SORT_ARIA": "Kategóriák rendezése", "DASHBOARD": { + "ACTION_FAILED": "A műveletet nem sikerült végrehajtani. Próbáld újra.", "CONTINUE_WATCHING": "Lejátszás folytatása", "LIVE_FAVORITES": "Most a kedvenc csatornákon", "LIVE_RECENT": "Élő tévénézés folytatása", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 8b6b930c3..4d97e5374 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Ordine del server", "CATEGORY_SORT_ARIA": "Ordina categorie", "DASHBOARD": { + "ACTION_FAILED": "Impossibile completare l'azione. Riprova.", "CONTINUE_WATCHING": "Continua a guardare", "LIVE_FAVORITES": "Live ora sui tuoi preferiti", "LIVE_RECENT": "Continua con la TV live", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 2484f389f..6cac2e8c8 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "サーバー順", "CATEGORY_SORT_ARIA": "カテゴリを並び替え", "DASHBOARD": { + "ACTION_FAILED": "操作を完了できませんでした。もう一度お試しください。", "CONTINUE_WATCHING": "視聴を再開", "LIVE_FAVORITES": "お気に入りで現在放送中", "LIVE_RECENT": "ライブTVの視聴を再開", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 3b9e32f43..be0dbbf34 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "서버 순서", "CATEGORY_SORT_ARIA": "카테고리 정렬", "DASHBOARD": { + "ACTION_FAILED": "작업을 완료할 수 없습니다. 다시 시도해 주세요.", "CONTINUE_WATCHING": "이어서 시청", "LIVE_FAVORITES": "즐겨찾기에서 지금 라이브 중인 채널입니다", "LIVE_RECENT": "라이브 TV 이어서 시청", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index c1af29454..c92d257cb 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Servervolgorde", "CATEGORY_SORT_ARIA": "Categorieën sorteren", "DASHBOARD": { + "ACTION_FAILED": "De actie kon niet worden voltooid. Probeer het opnieuw.", "CONTINUE_WATCHING": "Verder kijken", "LIVE_FAVORITES": "Nu live op je favorieten", "LIVE_RECENT": "Verder kijken met live tv", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 35b445c87..01282f6a9 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Kolejność serwera", "CATEGORY_SORT_ARIA": "Sortuj kategorie", "DASHBOARD": { + "ACTION_FAILED": "Nie udało się wykonać akcji. Spróbuj ponownie.", "CONTINUE_WATCHING": "Kontynuuj oglądanie", "LIVE_FAVORITES": "Na żywo w Twoich ulubionych", "LIVE_RECENT": "Kontynuuj oglądanie na żywo", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index bea2bbc82..54b113a83 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Ordem do servidor", "CATEGORY_SORT_ARIA": "Ordenar categorias", "DASHBOARD": { + "ACTION_FAILED": "Não foi possível concluir a ação. Tente novamente.", "CONTINUE_WATCHING": "Continuar assistindo", "LIVE_FAVORITES": "Ao vivo agora nos seus favoritos", "LIVE_RECENT": "Continuar com TV ao vivo", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index c8d559642..d32072550 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Порядок сервера", "CATEGORY_SORT_ARIA": "Сортировать категории", "DASHBOARD": { + "ACTION_FAILED": "Не удалось выполнить действие. Попробуйте ещё раз.", "CONTINUE_WATCHING": "Продолжить просмотр", "LIVE_FAVORITES": "Сейчас в эфире на избранных каналах", "LIVE_RECENT": "Продолжить просмотр прямого эфира", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 39f6cd0b4..6e2edfeab 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Sunucu sıralaması", "CATEGORY_SORT_ARIA": "Kategorileri sırala", "DASHBOARD": { + "ACTION_FAILED": "İşlem tamamlanamadı. Lütfen tekrar deneyin.", "CONTINUE_WATCHING": "İzlemeye Devam Et", "LIVE_FAVORITES": "Favorilerinizde şu an canlı", "LIVE_RECENT": "Canlı TV ile devam et", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index a57414d44..3f3a1ed27 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "服务器排序", "CATEGORY_SORT_ARIA": "排序分类", "DASHBOARD": { + "ACTION_FAILED": "无法完成该操作。请重试。", "CONTINUE_WATCHING": "继续观看", "LIVE_FAVORITES": "收藏频道正在播放", "LIVE_RECENT": "继续观看直播", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 2b6c312e2..1d1123e3b 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "伺服器排序", "CATEGORY_SORT_ARIA": "排序分類", "DASHBOARD": { + "ACTION_FAILED": "無法完成該操作。請重試。", "CONTINUE_WATCHING": "繼續觀看", "LIVE_FAVORITES": "我的最愛正在直播", "LIVE_RECENT": "繼續觀看直播電視", diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 3bfea503b..27b64464b 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -250,7 +250,12 @@ Season navigation inside `SeasonContainerComponent` uses season tabs (`SeasonTabsComponent`; a dropdown beyond 6 seasons) instead of the old seasons-grid + "Back to seasons" level. A season is auto-selected (inline-playing episode's season → most recently updated in-progress -episode's season → first) and the auto-selection emits `seasonSelected`, +episode's season → earliest season with unwatched episodes → latest +non-empty season; Stalker lazy-VOD series with unhydrated seasons pin +the fallback to the first season because their watched state is unknown, +and the empty→loaded positions flip caused by the session's own watched +toggles never re-resolves the selection) and the +auto-selection emits `seasonSelected`, so host lazy-load/enrichment hooks (Stalker VOD-series episode fetch, TMDB season fetch, Xtream `enrichSelectedSerialSeason`) fire on open without a click. Switching tabs never stops playback; a "back to playing diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index e45fbcb93..c6f48ef63 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -23,12 +23,15 @@ Related: - Dashboard `Global Favorites` and `Recently Watched` widgets hand off Xtream and Stalker movies/series into the matching global collection route with detail pre-opened. -- Dashboard Continue Watching handoffs for Xtream series may additionally carry - a one-shot season/episode resume target. The collection-owned Xtream detail - consumes it after its episode positions load and starts that exact episode; - opening the series from the collection grid itself remains detail-only. If the - positions load fails, the target stays unconsumed and the handoff degrades to - detail-only rather than starting the episode at offset zero. +- The dashboard hero CTA and the Continue Watching cards' explicit "Resume + episode" ⋮ action for Xtream series carry a one-shot season/episode resume + target. The collection-owned Xtream detail consumes it after its episode + positions load and starts that exact episode; the cards' default click is + detail-only (movie-like), as is opening the series from the collection grid + itself. If the positions load fails, the target stays unconsumed and the + handoff degrades to detail-only rather than starting the episode at offset + zero. Continue Watching cards also expose "Mark as Watched" (maxes out the + tracked position row) and "Remove from history" in the same ⋮ menu. - Ready Download Manager cards open one of the three focused `downloads/:downloadId` routes. These local details hide the workspace context panel, play only finalized local files, and show only locally diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts index 4424b8d3c..0d6c42964 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts @@ -53,7 +53,15 @@ export interface CollectionViewState { export function getRecentItemNavigation( item: PortalRecentItem, - seriesResume?: SeriesResumeTarget | null + seriesResume?: SeriesResumeTarget | null, + options?: { + /** + * Use `seriesResume` only to rewrite an episode-keyed recent row to + * its parent series identity, without carrying the auto-play resume + * handoff in the navigation state (detail-only clicks, issue #1441). + */ + resumeIdentityOnly?: boolean; + } ): WorkspaceNavigationTarget { if (item.type === 'live') { return buildLiveCollectionNavigationTarget({ @@ -85,7 +93,9 @@ export function getRecentItemNavigation( return buildGlobalCollectionDetailNavigationTarget( 'recent', detailItem, - item.type === 'series' ? seriesResume : null + item.type === 'series' && !options?.resumeIdentityOnly + ? seriesResume + : null ); } diff --git a/libs/ui/components/src/lib/season-container/season-container.auto-season.spec.ts b/libs/ui/components/src/lib/season-container/season-container.auto-season.spec.ts new file mode 100644 index 000000000..c01ff7be4 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-container.auto-season.spec.ts @@ -0,0 +1,208 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { SeasonDownloadCoordinator } from '@iptvnator/portal/shared/data-access'; +import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import { DownloadsService } from '@iptvnator/services'; +import { SeasonContainerComponent } from './season-container.component'; + +// Coverage for the default-season fallback (issue #1441): earliest season +// with unwatched episodes, or the latest season once everything loaded is +// watched. The higher-priority auto-select rules (playing episode's season, +// most recent in-progress season) live in season-container.component.spec.ts, +// which sits at the max-lines cap. + +function createEpisode( + overrides: Partial = {} +): XtreamSerieEpisode { + return { + id: '101', + episode_num: 1, + title: 'Pilot', + container_extension: 'mp4', + info: { duration: '45 min' }, + custom_sid: '', + added: '', + season: 1, + direct_source: '', + ...overrides, + } as XtreamSerieEpisode; +} + +const watchedPosition = (contentXtreamId: number) => ({ + contentXtreamId, + contentType: 'episode' as const, + positionSeconds: 2700, + durationSeconds: 2700, + updatedAt: '2026-07-01T00:00:00.000Z', +}); + +describe('SeasonContainerComponent default-season fallback', () => { + let fixture: ComponentFixture; + let component: SeasonContainerComponent; + + const setRequiredInputs = ( + seasons: Record + ) => { + fixture.componentRef.setInput('seasons', seasons); + fixture.componentRef.setInput('seriesId', 20); + fixture.componentRef.setInput('playlistId', 'playlist-1'); + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + SeasonContainerComponent, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: DownloadsService, + useValue: { + isAvailable: signal(false), + hasAuthoritativeDownloadList: signal(false), + hasLoadedDownloads: signal(false), + downloads: signal([]), + }, + }, + { provide: MatDialog, useValue: { open: jest.fn() } }, + SeasonDownloadCoordinator, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SeasonContainerComponent); + component = fixture.componentInstance; + }); + + it('auto-selects the earliest season with unwatched episodes when nothing is in progress', () => { + // Season 1 fully watched, seasons 2 and 3 untouched → land on 2. + fixture.componentRef.setInput( + 'playbackPositions', + new Map([[101, watchedPosition(101)]]) + ); + setRequiredInputs({ + '1': [createEpisode()], + '2': [createEpisode({ id: '201', season: 2 })], + '3': [createEpisode({ id: '301', season: 3 })], + }); + fixture.detectChanges(); + + expect(component.selectedSeason()).toBe('2'); + }); + + it('auto-selects the latest season once every loaded episode is watched', () => { + fixture.componentRef.setInput( + 'playbackPositions', + new Map([ + [101, watchedPosition(101)], + [201, watchedPosition(201)], + ]) + ); + setRequiredInputs({ + '1': [createEpisode()], + '2': [createEpisode({ id: '201', season: 2 })], + }); + fixture.detectChanges(); + + expect(component.selectedSeason()).toBe('2'); + }); + + it('keeps the first season without positions', () => { + setRequiredInputs({ + '1': [createEpisode()], + '2': [createEpisode({ id: '201', season: 2 })], + }); + fixture.detectChanges(); + + expect(component.selectedSeason()).toBe('1'); + }); + + it('keeps the first-season fallback while lazy seasons are still unloaded', () => { + // Stalker lazy VOD: unhydrated seasons have unknown watched state, + // so a watched season 1 must not push the selection past them. + fixture.componentRef.setInput('hasUnloadedSeasons', true); + fixture.componentRef.setInput( + 'playbackPositions', + new Map([[101, watchedPosition(101)]]) + ); + setRequiredInputs({ + '1': [createEpisode()], + '2': [], + }); + fixture.detectChanges(); + + expect(component.selectedSeason()).toBe('1'); + }); + + it('skips loaded-but-empty seasons in the all-watched fallback', () => { + // A trailing empty season is a valid Stalker answer; the latest + // season WITH episodes wins over it. + fixture.componentRef.setInput( + 'playbackPositions', + new Map([ + [101, watchedPosition(101)], + [201, watchedPosition(201)], + ]) + ); + setRequiredInputs({ + '1': [createEpisode()], + '2': [createEpisode({ id: '201', season: 2 })], + '3': [], + }); + fixture.detectChanges(); + + expect(component.selectedSeason()).toBe('2'); + }); + + it('keeps the selected season when a local watched toggle fills the positions map', () => { + // Marking season 1 watched on a fresh profile flips the positions + // map empty→non-empty; that echo of the user's own action must not + // re-resolve the selection and jump to season 2 (issue #1441 CI + // regression). + const episode = createEpisode(); + setRequiredInputs({ + '1': [episode], + '2': [createEpisode({ id: '201', season: 2 })], + }); + fixture.detectChanges(); + expect(component.selectedSeason()).toBe('1'); + + component.toggleWatched(new Event('click'), episode); + fixture.componentRef.setInput( + 'playbackPositions', + new Map([[101, watchedPosition(101)]]) + ); + fixture.detectChanges(); + + expect(component.selectedSeason()).toBe('1'); + }); + + it('still prefers an in-progress season over the unwatched fallback', () => { + // Season 1 has an in-progress episode; season 2 is unwatched — the + // resume rule outranks the fallback. + fixture.componentRef.setInput( + 'playbackPositions', + new Map([ + [ + 101, + { + ...watchedPosition(101), + positionSeconds: 500, + }, + ], + ]) + ); + setRequiredInputs({ + '1': [createEpisode()], + '2': [createEpisode({ id: '201', season: 2 })], + }); + fixture.detectChanges(); + + expect(component.selectedSeason()).toBe('1'); + }); +}); diff --git a/libs/ui/components/src/lib/season-container/season-container.component.ts b/libs/ui/components/src/lib/season-container/season-container.component.ts index 4cd6c5060..7cc9d8638 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.ts @@ -144,10 +144,14 @@ export class SeasonContainerComponent implements OnInit { /** * Selected season. Auto-resolves when the season key set changes or when * playback positions first arrive (priority: inline-playing episode's - * season → most recently updated in-progress episode's season → first - * season); user tab clicks write to it and stick until the auto-select + * season → most recently updated in-progress episode's season → earliest + * season with unwatched episodes → latest season, with unhydrated + * lazy-VOD seasons pinning the fallback to the first season); user tab + * clicks write to it and stick until the auto-select * key changes. Ongoing position saves do not reset the selection — only - * the empty→loaded transition of the positions map does. + * the empty→loaded transition of the positions map does, and even that + * is ignored once this session toggled watched state itself (the flip is + * then an echo of the local action, not an initial load). */ readonly selectedSeason = signal(undefined); @@ -163,6 +167,14 @@ export class SeasonContainerComponent implements OnInit { }` ); private lastAutoSelectKey: string | null = null; + private lastAutoSelectSeasonSet: string | null = null; + /** + * True once this session toggled watched state itself. From then on an + * empty↔loaded flip of the positions map is the echo of that action, not + * an async initial load — re-resolving on it would yank the user off the + * season they just marked (e.g. all-watched season 1 → jump to season 2). + */ + private hasLocalWatchedMutation = false; /** * Show thumbnails in the list view only when episodes have genuinely @@ -210,10 +222,14 @@ export class SeasonContainerComponent implements OnInit { activeEpisodeId: this.activeEpisodeId, openingEpisodeId: this.openingEpisodeId, isEpisodeWatched: (episode) => this.isEpisodeWatched(episode), - emitSeasonToggle: (request) => - this.seasonPlaybackToggleRequested.emit(request), - emitSeriesToggle: (request) => - this.seriesPlaybackToggleRequested.emit(request), + emitSeasonToggle: (request) => { + this.hasLocalWatchedMutation = true; + this.seasonPlaybackToggleRequested.emit(request); + }, + emitSeriesToggle: (request) => { + this.hasLocalWatchedMutation = true; + this.seriesPlaybackToggleRequested.emit(request); + }, }); effect(() => { @@ -221,7 +237,19 @@ export class SeasonContainerComponent implements OnInit { if (key === this.lastAutoSelectKey) { return; } + const seasonSet = untracked(() => + this.sortedSeasonKeys().join('|') + ); + const seasonSetUnchanged = + seasonSet === this.lastAutoSelectSeasonSet; this.lastAutoSelectKey = key; + this.lastAutoSelectSeasonSet = seasonSet; + // A positions-emptiness flip after a local watched toggle keeps + // the current selection; only the async initial positions load + // (or a season-set change) re-resolves the season. + if (seasonSetUnchanged && this.hasLocalWatchedMutation) { + return; + } this.selectedSeason.set(untracked(() => this.resolveAutoSeason())); }); @@ -313,6 +341,7 @@ export class SeasonContainerComponent implements OnInit { this.logger.warn('Cannot toggle watched: no playlist ID'); return; } + this.hasLocalWatchedMutation = true; const contentXtreamId = this.getEpisodeContentId(episode); const currentPosition = this.getEpisodePosition(episode); @@ -410,7 +439,36 @@ export class SeasonContainerComponent implements OnInit { } const resumeSeason = this.findMostRecentInProgressSeason(); - return resumeSeason ?? keys[0]; + return resumeSeason ?? this.resolveDefaultSeason(keys); + } + + /** + * Fallback when nothing is playing or in progress: the earliest season + * with unwatched episodes, or — once everything loaded is watched — the + * latest non-empty season, where new episodes land (issue #1441). + * Loaded-but-empty seasons (a valid Stalker answer) are never picked over + * one that has episodes. Stalker lazy-VOD series with unhydrated seasons + * keep the first season: their watched state is unknown, so skipping + * past them would be a guess. + */ + private resolveDefaultSeason(keys: readonly string[]): string { + if (this.hasUnloadedSeasons()) { + return keys[0]; + } + + const episodeCounts = this.episodeCounts(); + const watchedCounts = this.watchedCounts(); + const firstUnwatched = keys.find((key) => { + const total = episodeCounts[key] ?? 0; + return total > 0 && (watchedCounts[key] ?? 0) < total; + }); + if (firstUnwatched) { + return firstUnwatched; + } + const latestWithEpisodes = [...keys] + .reverse() + .find((key) => (episodeCounts[key] ?? 0) > 0); + return latestWithEpisodes ?? keys[0]; } private findMostRecentInProgressSeason(): string | null { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 71cb19fe5..cdd47daf2 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -56,8 +56,10 @@ import { getGlobalFavoriteLink as getGlobalFavoriteLinkUtil, getGlobalFavoriteNavigationState as getGlobalFavoriteNavigationStateUtil, getPlaylistLink as getPlaylistLinkUtil, + getRecentItemDetailNavigationState as getRecentItemDetailNavigationStateUtil, getRecentItemLink as getRecentItemLinkUtil, getRecentItemNavigationState as getRecentItemNavigationStateUtil, + getRecentItemResumeNavigation as getRecentItemResumeNavigationUtil, getRecentlyAddedLink as getRecentlyAddedLinkUtil, getRecentlyAddedNavigationState as getRecentlyAddedNavigationStateUtil, isTypeInKind as isTypeInKindUtil, @@ -899,6 +901,52 @@ export class DashboardDataService { ); } + getRecentItemDetailNavigationState( + item: GlobalRecentItem + ): WorkspaceNavigationTarget['state'] { + return getRecentItemDetailNavigationStateUtil( + item, + this.getPlaybackPositionForItem(item) + ); + } + + getRecentItemResumeNavigation( + item: GlobalRecentItem + ): WorkspaceNavigationTarget | null { + return getRecentItemResumeNavigationUtil( + item, + this.getPlaybackPositionForItem(item) + ); + } + + /** + * Mark the item's tracked position as fully watched by maxing out the + * existing row. Reusing the stored row keeps the provider-specific + * identity fields (Stalker scoped episode ids included) intact; without + * a row or a known duration there is nothing truthful to write, so the + * action reports false and the caller hides/ignores it. Uses the strict + * save boundary and rethrows persistence failures — the non-strict save + * swallows them, and reloading an unchanged position while reporting + * success would silently lie to the user. + */ + async markRecentItemWatched(item: GlobalRecentItem): Promise { + const position = this.getPlaybackPositionForItem(item); + if (!position?.durationSeconds || position.durationSeconds <= 0) { + return false; + } + + await this.playbackPositions.savePlaybackPositionOrThrow( + item.playlist_id, + { + ...position, + positionSeconds: position.durationSeconds, + updatedAt: new Date().toISOString(), + } + ); + await this.reloadPlaybackPositions(); + return true; + } + async removeGlobalRecentItem(item: GlobalRecentItem): Promise { if (item.source === 'xtream') { if (this.hasPortalActivityStorage) { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts index d3ef2f20b..8e16e73d4 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts @@ -2,7 +2,11 @@ import type { PlaybackPositionData, PortalRecentItem, } from '@iptvnator/shared/interfaces'; -import { getRecentItemNavigationState } from './dashboard-navigation.util'; +import { + getRecentItemDetailNavigationState, + getRecentItemNavigationState, + getRecentItemResumeNavigation, +} from './dashboard-navigation.util'; const recentSeries: PortalRecentItem = { id: 200, @@ -33,7 +37,7 @@ function episodePosition( type RecentNavigationState = { openCollectionDetailItem?: { - item?: unknown; + item?: { uid?: string; xtreamId?: number }; seriesResume?: unknown; }; }; @@ -66,3 +70,67 @@ describe('getRecentItemNavigationState series resume target', () => { expect(state.openCollectionDetailItem?.item).toBeDefined(); }); }); + +describe('getRecentItemDetailNavigationState', () => { + it('never carries a resume target, even for an in-progress series', () => { + // Continue Watching default click is detail-only (issue #1441) — + // resuming is an explicit card action instead. + const state = getRecentItemDetailNavigationState( + recentSeries, + episodePosition() + ) as RecentNavigationState; + + expect(state.openCollectionDetailItem?.seriesResume).toBeUndefined(); + expect(state.openCollectionDetailItem?.item).toBeDefined(); + }); + + it('still rewrites an episode-keyed row to the parent series identity', () => { + // Legacy recent rows can carry the EPISODE id in xtream_id; the + // position names the parent series, and the detail must target it + // even without the resume handoff — including for watched rows. + const state = getRecentItemDetailNavigationState( + { ...recentSeries, xtream_id: 4007 }, + episodePosition({ positionSeconds: 1800 }) + ) as RecentNavigationState; + + expect(state.openCollectionDetailItem?.item?.xtreamId).toBe(4000); + expect(state.openCollectionDetailItem?.seriesResume).toBeUndefined(); + }); +}); + +describe('getRecentItemResumeNavigation', () => { + it('builds a full navigation target for an in-progress episode', () => { + const navigation = getRecentItemResumeNavigation( + recentSeries, + episodePosition() + ); + + expect(navigation?.link).toEqual(['/workspace', 'global-recent']); + const state = navigation?.state as RecentNavigationState; + expect(state.openCollectionDetailItem?.seriesResume).toEqual({ + seriesXtreamId: 4000, + contentXtreamId: 4007, + seasonNumber: 3, + episodeNumber: 7, + }); + }); + + it('returns null for watched rows', () => { + expect( + getRecentItemResumeNavigation( + recentSeries, + episodePosition({ positionSeconds: 1800 }) + ) + ).toBeNull(); + }); + + it('returns null for non-Xtream sources and missing positions', () => { + expect( + getRecentItemResumeNavigation( + { ...recentSeries, source: 'stalker' }, + episodePosition() + ) + ).toBeNull(); + expect(getRecentItemResumeNavigation(recentSeries, null)).toBeNull(); + }); +}); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts index 845daf01e..56b025258 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts @@ -70,7 +70,60 @@ export function getRecentItemNavigationState( ).state; } -function buildRecentSeriesResumeTarget( +/** + * Detail-only navigation state: never carries a series resume target, so the + * handoff opens the detail page without auto-playing. Used by the Continue + * Watching cards' default click (movie-like behavior, issue #1441); the hero + * CTA and the cards' explicit Resume action keep the resume handoff. The + * position still matters here: a legacy episode-keyed recent row needs its + * identity rewritten to the parent series the position names, or the detail + * would target the episode id. + */ +export function getRecentItemDetailNavigationState( + item: PortalRecentItem, + playbackPosition?: PlaybackPositionData | null +): WorkspaceNavigationTarget['state'] { + return getRecentItemNavigation( + item, + buildRecentSeriesIdentityTarget(item, playbackPosition), + { resumeIdentityOnly: true } + ).state; +} + +/** + * Full navigation target carrying the one-shot resume handoff, or null when + * the item/position cannot produce one (non-Xtream, non-series, watched or + * coordinate-less rows). Powers the card's explicit "Resume episode" action. + */ +export function getRecentItemResumeNavigation( + item: PortalRecentItem, + playbackPosition?: PlaybackPositionData | null +): WorkspaceNavigationTarget | null { + const resumeTarget = buildRecentSeriesResumeTarget(item, playbackPosition); + return resumeTarget ? getRecentItemNavigation(item, resumeTarget) : null; +} + +export function buildRecentSeriesResumeTarget( + item: PortalRecentItem, + playbackPosition?: PlaybackPositionData | null +): SeriesResumeTarget | null { + // A watched row is a completion marker (natural finish or a manual/bulk + // "mark watched"), not resumable progress — auto-playing it would start + // the episode at its end. Detail-only handoff lets the series page's + // quick-start pick the first unwatched episode instead. + if (isPortalPlaybackWatched(playbackPosition)) { + return null; + } + + return buildRecentSeriesIdentityTarget(item, playbackPosition); +} + +/** + * Series coordinates named by an episode position, without the watched + * guard: even a finished episode still identifies its parent series, which + * the detail-only click needs for the episode-keyed-row identity rewrite. + */ +function buildRecentSeriesIdentityTarget( item: PortalRecentItem, playbackPosition?: PlaybackPositionData | null ): SeriesResumeTarget | null { @@ -82,14 +135,6 @@ function buildRecentSeriesResumeTarget( return null; } - // A watched row is a completion marker (natural finish or a manual/bulk - // "mark watched"), not resumable progress — auto-playing it would start - // the episode at its end. Detail-only handoff lets the series page's - // quick-start pick the first unwatched episode instead. - if (isPortalPlaybackWatched(playbackPosition)) { - return null; - } - // Episode-keyed recent rows make item.xtream_id an episode id, so only a // position row that names its parent series can produce a resume target; // legacy rows without seriesXtreamId stay detail-only. diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts index f84ec2dee..5e079f575 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts @@ -68,6 +68,50 @@ export function buildDashboardSourceActions( return actions; } +export type DashboardContinueWatchingActionId = + | 'resume' + | 'mark-watched' + | 'remove-from-history'; + +/** + * ⋮ menu for Continue Watching cards (issue #1441). The default card click is + * detail-only (movie-like); the explicit Resume action carries the one-shot + * auto-play handoff instead. Reuses existing translation keys so no new + * strings are needed across the language files. + */ +export function buildDashboardContinueWatchingActions(options: { + canResume: boolean; + canMarkWatched: boolean; +}): DashboardRailAction[] { + const actions: DashboardRailAction[] = []; + + if (options.canResume) { + actions.push({ + id: 'resume', + icon: 'play_arrow', + labelKey: 'XTREAM.RESUME_EPISODE', + }); + } + + if (options.canMarkWatched) { + actions.push({ + id: 'mark-watched', + icon: 'check_circle', + labelKey: 'XTREAM.MARK_WATCHED', + }); + } + + actions.push({ + id: 'remove-from-history', + icon: 'delete', + labelKey: 'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY', + destructive: true, + separatorBefore: actions.length > 0, + }); + + return actions; +} + export function liveRailTitleKeyForSource( source: 'favorites' | 'recent' ): string { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index 8d725573e..a4beb0101 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -215,6 +215,7 @@ [seeAllLink]="['/workspace/global-recent']" [seeAllState]="continueWatchingSeeAllState()" [testId]="'dashboard-continue-watching-rail'" + (actionSelected)="onContinueWatchingActionSelected($event)" /> } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts index 80ffce6cd..0b34f85ac 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts @@ -2,6 +2,7 @@ import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces'; import { DEFAULT_DASHBOARD_RAILS_SETTINGS } from '@iptvnator/shared/interfaces'; import { buildDashboardCollectionViewState, + buildDashboardContinueWatchingActions, buildDashboardRailSeeAllState, buildDashboardSourceActions, liveRailTitleKeyForSource, @@ -96,6 +97,51 @@ describe('buildDashboardSourceActions', () => { }); }); +describe('buildDashboardContinueWatchingActions', () => { + const actionIds = (options: { + canResume: boolean; + canMarkWatched: boolean; + }) => + buildDashboardContinueWatchingActions(options).map( + (action) => action.id + ); + + it('exposes resume, mark-watched, and remove for a resumable series', () => { + expect(actionIds({ canResume: true, canMarkWatched: true })).toEqual([ + 'resume', + 'mark-watched', + 'remove-from-history', + ]); + }); + + it('keeps only remove when the card has no resumable or markable position', () => { + const actions = buildDashboardContinueWatchingActions({ + canResume: false, + canMarkWatched: false, + }); + + expect(actions.map((action) => action.id)).toEqual([ + 'remove-from-history', + ]); + // Sole entry → no leading separator above it. + expect(actions[0].separatorBefore).toBe(false); + expect(actions[0].destructive).toBe(true); + }); + + it('separates the destructive remove entry from the content actions', () => { + const actions = buildDashboardContinueWatchingActions({ + canResume: false, + canMarkWatched: true, + }); + + expect(actions.map((action) => action.id)).toEqual([ + 'mark-watched', + 'remove-from-history', + ]); + expect(actions[1].separatorBefore).toBe(true); + }); +}); + describe('resolveDashboardHeroArtwork', () => { const item = { title: 'Broken Hero', diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index d185a330c..18ffa2c45 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -20,7 +20,8 @@ import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { RouterLink } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; +import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util'; import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { @@ -78,6 +79,7 @@ import { } from './dashboard-playback.utils'; import { buildDashboardCollectionViewState, + buildDashboardContinueWatchingActions, buildDashboardRailSeeAllState, buildDashboardSourceActions, liveRailTitleKeyForSource, @@ -87,7 +89,10 @@ import { SKELETON_CARDS_PER_RAIL, SKELETON_RAILS, } from './dashboard-rail.utils'; -import type { DashboardSourceActionId } from './dashboard-rail.utils'; +import type { + DashboardContinueWatchingActionId, + DashboardSourceActionId, +} from './dashboard-rail.utils'; @Component({ selector: 'lib-workspace-dashboard-rails', @@ -114,6 +119,7 @@ export class WorkspaceDashboardRailsComponent { private readonly playlistRefreshAction = inject( PlaylistRefreshActionService ); + private readonly router = inject(Router); private readonly snackBar = inject(MatSnackBar); private readonly store = inject(Store); private readonly translate = inject(TranslateService); @@ -538,6 +544,63 @@ export class WorkspaceDashboardRailsComponent { } } + onContinueWatchingActionSelected( + selection: DashboardRailActionSelection + ): void { + const item = this.data + .globalRecentVodItems() + .find( + (candidate) => + this.recentCardId(candidate) === selection.card.id + ); + + if (!item) { + return; + } + + switch (selection.action.id as DashboardContinueWatchingActionId) { + case 'resume': + this.resumeRecentItem(item); + break; + case 'mark-watched': + this.runContinueWatchingMutation(() => + this.data.markRecentItemWatched(item) + ); + break; + case 'remove-from-history': + this.runContinueWatchingMutation(() => + this.data.removeGlobalRecentItem(item) + ); + break; + } + } + + private resumeRecentItem(item: GlobalRecentItem): void { + const navigation = this.data.getRecentItemResumeNavigation(item); + if (!navigation) { + return; + } + void this.router.navigate(navigation.link, { + state: navigation.state, + }); + } + + /** + * A rejected persistence write leaves the card unchanged, so tell the + * user instead of failing silently with an unhandled rejection. + */ + private runContinueWatchingMutation( + mutation: () => Promise + ): void { + mutation().catch(() => { + this.snackBar.open( + this.t('WORKSPACE.DASHBOARD.ACTION_FAILED'), + undefined, + { duration: 5000 } + ); + }); + } + private enrichLiveCards( cards: readonly DashboardRailCard[] ): DashboardRailCard[] { @@ -597,7 +660,7 @@ export class WorkspaceDashboardRailsComponent { ) : null; return { - id: `recent-${item.id}-${item.playlist_id}-${item.viewed_at}`, + id: this.recentCardId(item), title: item.title, subtitle: `${this.data.getRecentItemProviderLabel(item)} · ${this.data.getRecentItemTypeLabel(item)}`, imageUrl: item.poster_url, @@ -605,12 +668,32 @@ export class WorkspaceDashboardRailsComponent { contentType: item.type, epgLookupKey: item.epg_lookup_key, link: this.data.getRecentItemLink(item), - state: this.data.getRecentItemNavigationState(item), + // Default click is detail-only for every card — an in-progress + // series no longer auto-plays on click (issue #1441); resuming + // moved to the ⋮ menu below. The hero CTA keeps the resume state. + state: this.data.getRecentItemDetailNavigationState(item), watchProgress, episodeBadge, + ...(item.type === 'movie' || item.type === 'series' + ? { + actions: buildDashboardContinueWatchingActions({ + canResume: + this.data.getRecentItemResumeNavigation(item) !== + null, + canMarkWatched: + !!position?.durationSeconds && + position.durationSeconds > 0 && + !isPortalPlaybackWatched(position), + }), + } + : {}), }; } + private recentCardId(item: GlobalRecentItem): string { + return `recent-${item.id}-${item.playlist_id}-${item.viewed_at}`; + } + private toFavoriteCard(item: DashboardFavoriteItem): DashboardRailCard { return { id: `fav-${item.id}-${item.playlist_id}-${item.added_at}`,