From 2ac0de752f0649f9a06f859f8f8657943abd3219 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:00:59 +0200 Subject: [PATCH] fix(skills): align repository guidance with implementation (#1315) * docs(skills): design implementation synchronization * docs(skills): plan implementation synchronization * fix(release): filter internal notes from public body * docs(release): synchronize release workflow guidance * fix(stalker): normalize catalog series flags * fix(stalker): preserve progress with scoped episode IDs * fix(playback): expose strict position persistence * docs(stalker): record series position compatibility * test(skills): validate repository skill contracts * fix(database): keep SQL trace values private * docs(skills): refresh Nx and SQLite ownership * docs(skills): align provider and UI guidance * docs(skills): tighten validated guidance * docs(release): require exact release pushes * style(electron): remove trailing blank line * fix(ci): classify repository skills coverage --- .changes/README.md | 14 +- .changes/database-redact-sql-traces.md | 6 + .changes/stalker-series-position-identity.md | 6 + .claude/skills/release-cut/SKILL.md | 120 +- .claude/skills/release-notes/SKILL.md | 73 +- .../skills/iptvnator-nx-architecture/SKILL.md | 98 +- .../iptvnator-sqlite-db-worker/SKILL.md | 76 +- .codex/skills/iptvnator-theme-style/SKILL.md | 57 +- .codex/skills/iptvnator-ui-design/SKILL.md | 47 +- .codex/skills/release-cut/SKILL.md | 120 +- .codex/skills/release-notes/SKILL.md | 73 +- .codex/skills/stalker-portal/SKILL.md | 101 +- .codex/skills/xtream-electron/SKILL.md | 68 +- .github/workflows/build-and-make.yaml | 15 +- AGENTS.md | 49 +- CLAUDE.md | 49 +- .../src/app/events/xtream.events.ts | 1 - .../src/app/services/debug-trace.spec.ts | 23 +- .../src/app/services/debug-trace.ts | 13 +- .../app/workers/database.worker-connection.ts | 8 +- .../portal-playback-positions.service.spec.ts | 128 ++ .../portal-playback-positions.service.ts | 46 + docs/architecture/iptvnator-ui-guidelines.md | 71 +- docs/architecture/nx-workspace-boundaries.md | 182 +- docs/architecture/sqlite-db-worker.md | 136 +- docs/architecture/stalker-epg.md | 18 +- docs/architecture/stalker-portal.md | 43 +- .../xtream-portal-compatibility.md | 34 + ...9-repository-skills-implementation-sync.md | 2013 +++++++++++++++++ ...itory-skills-implementation-sync-design.md | 299 +++ .../util/src/lib/portal-playback-positions.ts | 9 + .../src/lib/stalker-series.adapters.spec.ts | 164 +- .../src/lib/stalker-series.adapters.ts | 65 +- .../src/lib/stalker-vod.utils.spec.ts | 20 + .../data-access/src/lib/stalker-vod.utils.ts | 5 +- .../with-stalker-series.feature.spec.ts | 42 +- .../features/with-stalker-series.feature.ts | 3 +- .../stalker-catalog-detail.component.ts | 4 +- .../stalker-catalog-facade.service.spec.ts | 111 +- .../src/lib/stalker-catalog-facade.service.ts | 7 +- ...lker-series-position-compatibility.spec.ts | 573 +++++ .../stalker-series-position-compatibility.ts | 203 ++ .../stalker-series-view.component.html | 2 +- .../stalker-series-view.component.ts | 494 +++- ...series-view.position-compatibility.spec.ts | 1199 ++++++++++ ...ck-position-runtime-bridge.service.spec.ts | 124 + ...layback-position-runtime-bridge.service.ts | 47 + .../database/src/lib/connection.spec.ts | 24 + libs/shared/database/src/lib/connection.ts | 26 +- .../src/lib/stalker-item.normalizer.spec.ts | 13 + .../src/lib/stalker-item.normalizer.ts | 2 +- libs/shared/logging/package.json | 11 + libs/shared/logging/project.json | 10 + libs/shared/logging/src/index.ts | 8 + .../logging/src/lib/sql-trace-summary.spec.ts | 58 + .../logging/src/lib/sql-trace-summary.ts | 58 + libs/ui/styles/_content-grid.scss | 6 +- libs/ui/styles/_index.scss | 11 +- libs/ui/styles/_portal-layout.scss | 6 +- libs/ui/styles/_portal-sidebar.scss | 6 +- package.json | 1 + tools/coverage/coverage-policy.json | 6 + tools/release/extract-changelog-section.mjs | 104 +- tools/release/project.json | 1 + tools/release/release-notes.test.mjs | 291 ++- tools/skills/project.json | 28 + tools/skills/validate-repository-skills.mjs | 287 +++ .../validate-repository-skills.test.mjs | 301 +++ 68 files changed, 7524 insertions(+), 793 deletions(-) create mode 100644 .changes/database-redact-sql-traces.md create mode 100644 .changes/stalker-series-position-identity.md create mode 100644 apps/web/src/app/services/portal-playback-positions.service.spec.ts create mode 100644 docs/superpowers/plans/2026-07-29-repository-skills-implementation-sync.md create mode 100644 docs/superpowers/specs/2026-07-29-repository-skills-implementation-sync-design.md create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts create mode 100644 libs/shared/logging/package.json create mode 100644 libs/shared/logging/src/lib/sql-trace-summary.spec.ts create mode 100644 libs/shared/logging/src/lib/sql-trace-summary.ts create mode 100644 tools/skills/project.json create mode 100644 tools/skills/validate-repository-skills.mjs create mode 100644 tools/skills/validate-repository-skills.test.mjs diff --git a/.changes/README.md b/.changes/README.md index e41282f03..b8be5c01e 100644 --- a/.changes/README.md +++ b/.changes/README.md @@ -49,14 +49,18 @@ The body is capped at 400 characters — depth belongs in the blog post. - ❌ "Fix off-by-one in `resolveEnrichmentSeasonNumber`" - ✅ "Series whose title carries a season marker no longer show the wrong season" -`type: internal` is for changes with no user-visible effect that are still worth -recording (dependency bumps with behaviour risk, packaging moves). They stay out -of the release body and blog post, and land collapsed in `CHANGELOG.md`. +`type: internal` records invisible maintenance. Internal notes stay collapsed in +`CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the +authored public GitHub body by +`extract-changelog-section.mjs --public`. GitHub's generated commit list remains +separate. An internal-only release can therefore have an empty authored body. ## When a note is not needed -Skip the note — and apply the `no-release-note` label — for test-only changes, -docs, CI/workflow plumbing, and pure refactors with no behaviour change. +The gate auto-exempts website, E2E and mock-server apps, `*.spec.{js,ts}`, +`*.e2e.{js,ts}`, snapshots, any `/testing/` path, and Markdown. For other +test-only, documentation, CI/workflow, or pure-refactor changes under +`apps/`/`libs/`, apply `no-release-note` when no user-visible note is warranted. ## Commands diff --git a/.changes/database-redact-sql-traces.md b/.changes/database-redact-sql-traces.md new file mode 100644 index 000000000..89d7c484c --- /dev/null +++ b/.changes/database-redact-sql-traces.md @@ -0,0 +1,6 @@ +--- +type: fix +area: database +--- + +SQLite diagnostics now record only statement types, preventing playlist credentials and other private values from appearing in trace logs. diff --git a/.changes/stalker-series-position-identity.md b/.changes/stalker-series-position-identity.md new file mode 100644 index 000000000..b2fb0624f --- /dev/null +++ b/.changes/stalker-series-position-identity.md @@ -0,0 +1,6 @@ +--- +type: fix +area: stalker +--- + +Stalker VOD series now report progress correctly when portals return boolean series flags, keep episode progress separate between shows, and resume positions saved by earlier versions. diff --git a/.claude/skills/release-cut/SKILL.md b/.claude/skills/release-cut/SKILL.md index 62e07edd5..2ff98f9b0 100644 --- a/.claude/skills/release-cut/SKILL.md +++ b/.claude/skills/release-cut/SKILL.md @@ -1,88 +1,74 @@ --- name: release-cut -description: Cut an IPTVnator release — bump the version, generate release notes from .changes/, scaffold the website post, tag, and verify the draft. Use when asked to release, cut a version, prepare release notes, or publish a new version. +description: Use when preparing, cutting, tagging, publishing, or verifying an IPTVnator release or its release assets. --- # Release Cut -The pipeline turns accumulated `.changes/*.md` notes into all three release -surfaces. Order matters: **the tag build extracts the CHANGELOG section into -the GitHub release body and fails if it is missing**, so the changelog step -is not optional. +The tag workflow authors the public GitHub body with +`node tools/release/extract-changelog-section.mjs --public "${VERSION}"`. +Keep the full changelog, including internal notes, committed before tagging. -## Sequence +## Preflight -1. **Pick the version** — deliberate choice, edit `version` in the root - `package.json`. Bare semver only: any suffix flips electron-updater into - prerelease mode and leaks into installer version fields. +Work from clean, current `master` with the intended remote named explicitly. +Confirm `package.json` contains bare semver, the exact `v` tag does not +exist locally or remotely, CI is green, and all notes validate. -2. **Review the notes** — read every file in `.changes/`. Fix wording (user - language, not reviewer language), then: +```bash +pnpm run release:notes:validate +pnpm run i18n:check +``` - ```bash - pnpm run release:notes:validate - ``` +## Generate -3. **Generate the changelog section** (idempotent per version — rerunning - replaces the section, so regenerate freely until it reads well): +1. Set `package.json.version`. +2. Run `pnpm run release:notes:changelog`. +3. Minor release: run `pnpm run release:notes:blog` and finish every editorial + field. Patch release: edit the existing `vX-Y` post; do not scaffold or + force-overwrite it. +4. Capture required manifest screenshots only against mock servers: + `pnpm nx run electron-backend:build-e2e`, then + `pnpm run release:screenshots`. +5. Consume notes only after reviewing all generated output: + `node tools/release/build-release-notes.mjs --consume`. - ```bash - pnpm run release:notes:changelog - ``` +The consume command is the destructive boundary: it deletes the direct note +files. Stage only release-owned files, including exact website post/assets and +`git add -A -- .changes`, then commit and create the exact tag. -4. **Scaffold the website post**: +```bash +git commit -m "chore(release): v0.24.0" +git tag v0.24.0 +``` - ```bash - pnpm run release:notes:blog - ``` +## Push and External Effects - Output is `apps/website/src/content/blog/v0-XX-release-notes.mdx` with - `draft: true`. The narrative intro, headlines, and `description` are - editorial — fill every `TODO` by hand. One post per **minor** version: - for a patch release, edit the existing post (the scaffold refuses to - overwrite without `--force`). +Push the named remote's `master` branch first, then push only the exact +`v` tag as a second command. Never use broad `git push --tags`. +For remote `upstream` and version `v0.25.1`, run exactly: -5. **Screenshots** — only from the fail-closed capture script against the - mock servers, never from a real playlist or account: real streams, logos, - and TMDB artwork are copyrighted, and credentials must never reach a - published image. +```bash +git push upstream master +git push upstream v0.25.1 +``` - ```bash - pnpm nx run electron-backend:build-e2e # once - pnpm run release:screenshots # all manifest shots, dark+light - ``` +Master and `v*` pushes can publish Docker images. The tag build creates a draft +GitHub release. Verify authored text plus generated commits and all required +macOS, Windows, DEB, RPM, Pacman (`.pacman`/`.pkg.tar.*`), AppImage, Snap, +Flatpak, updater metadata, blockmaps, and +`linux-frame-copy-runtime-sources.tar.xz`. - Output goes to `apps/website/public/blog/v0-XX/screenshots/`. New feature - to showcase = new entry in `tools/release/screenshots.manifest.json` - (slug must match the note's `screenshot:` field). The run aborts and - deletes its frames on any guard violation (real-DB touch, external - request, credential-shaped text in frame, TMDB active). +After verification, manually publish the GitHub release. That publication +automatically verifies its Snap assets and uploads them to `edge`. +Installed-Snap smoke and candidate/stable promotion remain manual. Keep the +blog draft during artifact verification; publish it in a follow-up commit and +verify the website deployment. -6. **Consume the notes** (the only destructive step): +## Failure Safety - ```bash - node tools/release/build-release-notes.mjs --consume - ``` +Missing CHANGELOG section: regenerate, commit, delete the bad tag locally and +remotely only after resolving its exact target, then retag. Never publish a +draft until the source archive and Snap contract pass. -7. **Commit, tag, push**: - - ```bash - git add CHANGELOG.md .changes apps/website package.json - git commit -m "chore(release): v0.XX.0" - git tag v0.XX.0 && git push && git push --tags - ``` - -8. **Verify the draft release** once `build-and-make.yaml` finishes: authored - notes on top, GitHub's generated commit list below, all platform assets - present (`.dmg`/`.zip` + `latest-mac.yml`, `.exe`/`.msi` + `latest.yml`, - `.deb`/`.rpm`/`.AppImage`/`.snap`/`.flatpak` + `latest-linux*.yml`, - blockmaps). Publish manually; flip the blog post to `draft: false`. - -## Failure modes - -- **create-release fails with "CHANGELOG.md has no section for X"** — step 3 - was skipped. Run it, commit, delete and re-push the tag. -- **Snap store publication** is a separate manual flow after the public - release exists (`publish-snap.yaml`). -- Post-release checklist candidates: i18n drift (`pnpm run i18n:check`), - update the website `v0-XX` blog assets, announce in Telegram. +The `.codex` and `.claude` copies of this skill must remain byte-identical. diff --git a/.claude/skills/release-notes/SKILL.md b/.claude/skills/release-notes/SKILL.md index 7ccb1338d..308456234 100644 --- a/.claude/skills/release-notes/SKILL.md +++ b/.claude/skills/release-notes/SKILL.md @@ -1,70 +1,49 @@ --- name: release-notes -description: Write the .changes/ release note that every PR with a user-visible change must include. Use when creating or finishing a PR that changes behavior in apps/ or libs/, when the "Release note gate" CI check fails, or when deciding whether the no-release-note label applies. +description: "Use when a change may need a .changes note, the Release note gate fails, or deciding whether type: internal or no-release-note applies." --- -# Release Notes (`.changes/`) +# Release Notes -Every PR with a user-visible change adds **one** note file under `.changes/`. -At release time the notes become the GitHub release body, the `CHANGELOG.md` -section, and the website blog scaffold. CI enforces this: the **Release note -gate** check fails any PR that touches runtime code under `apps/` or `libs/` -without an added `.changes/*.md` file or the `no-release-note` label. +Every user-visible change gets one direct `.changes/-.md` file. The +area matches the conventional-commit scope; the body is present tense, user +language, one to three sentences, and at most 400 characters. -## File format - -Name: `.changes/-.md` — `area` matches the -conventional-commit scope of the PR. +## Format ```markdown --- -type: feature -area: playback -issues: [1187] -screenshot: up-next-rail +type: fix +area: stalker +issues: [1234] +screenshot: optional-manifest-slug --- -Series now show an "Up Next" rail beside the player on wide windows: the rest -of the current season, watch progress, and click-to-play inline. +Stalker series now resume the correct episode. ``` -| Field | Required | Value | -| ------------ | -------- | ---------------------------------------------------- | -| `type` | yes | `breaking` / `feature` / `fix` / `perf` / `internal` | -| `area` | yes | lowercase slug = conventional-commit scope | -| `issues` | no | `[1187]` or bare `1187` — issues this PR closes | -| `screenshot` | no | slug from the release screenshot manifest | +`type` is `breaking`, `feature`, `fix`, `perf`, or `internal`. Omit optional +fields instead of inventing values. Never add a version or PR number. -- **No version field.** The release version is chosen at release time. -- **Never write a PR number.** The generator resolves it from git. -- Unknown keys fail validation — this is what catches typos like `scopr:`. +`internal` records invisible maintenance. It stays collapsed in `CHANGELOG.md` +but is omitted from the blog and the authored public GitHub body. GitHub's +generated commit list may still mention the underlying commits. -## Writing the body +## Skip or Label -One to three sentences, present tense, max 400 characters, **written for a -user, not a reviewer**: +The gate auto-exempts website, E2E and mock-server apps, `*.spec.{js,ts}`, +`*.e2e.{js,ts}`, snapshots, any `/testing/` path, and Markdown. Other test-only, +docs, CI, workflow, or pure-refactor PRs use `no-release-note` when the gate +would otherwise require a note. At least one newly added direct +`.changes/*.md` file satisfies the gate. -- ❌ "Refactor `WebVideoControlsAdapter` to hoist volume state" -- ✅ "The player now remembers volume between episodes" -- ❌ "Fix off-by-one in `resolveEnrichmentSeasonNumber`" -- ✅ "Series with a season marker in the title no longer show the wrong season" - -`type: internal` is for changes worth recording but invisible to users -(dependency bumps with behavior risk, packaging moves). They are excluded -from the release body and blog, and collapsed in `CHANGELOG.md`. - -## When to skip (`no-release-note` label) - -Test-only changes, docs, CI/workflow plumbing, pure refactors with no -behavior change. The gate auto-exempts `*.spec.ts`, `*.e2e.ts`, -`__snapshots__/`, `apps/website/`, `apps/*-e2e/`, `apps/*-mock-server/`, -`libs/shared/testing/` and `*.md` — if only those changed, no label needed. - -## Verify before finishing +## Verify ```bash pnpm run release:notes:validate ``` -Full format reference: `.changes/README.md`. Gate policy: +Full format: `.changes/README.md`. Gate policy: `tools/release/check-release-note-gate.mjs`. + +The `.codex` and `.claude` copies of this skill must remain byte-identical. diff --git a/.codex/skills/iptvnator-nx-architecture/SKILL.md b/.codex/skills/iptvnator-nx-architecture/SKILL.md index 94e643d4a..0e56a0817 100644 --- a/.codex/skills/iptvnator-nx-architecture/SKILL.md +++ b/.codex/skills/iptvnator-nx-architecture/SKILL.md @@ -1,44 +1,78 @@ --- name: iptvnator-nx-architecture -description: Repository-specific Nx monorepo structure, library placement rules, scoped path aliases, and migration guardrails for portal/workspace/app code. +description: Use when deciding where IPTVnator code belongs, creating or moving Nx projects, changing scoped aliases or tags, editing lint targets, or validating module boundaries. --- # IPTVnator Nx Architecture -Use this skill when deciding where code belongs, extracting libraries, changing imports, editing project tags, or refactoring portal/workspace/app boundaries. +## Discover Before Deciding -## Project Shape - -- `apps/web`: Angular renderer application. -- `apps/electron-backend`: Electron main process and native/runtime integration. -- `apps/*-e2e`: Playwright E2E projects. -- `apps/*-mock-server`: local development and E2E mock servers. -- `libs/playlist/*`: M3U/import/shared playlist functionality. -- `libs/portal/*`: Xtream, Stalker, and provider-neutral portal functionality. -- `libs/workspace/*`: workspace shell and dashboard. -- `libs/ui/*`: provider-neutral UI, playback, EPG, remote control, and pipes. -- `libs/shared/*`: contracts, database, and pure utility code. - -## Import Policy - -- Use scoped aliases from `tsconfig.base.json`, for example `@iptvnator/services` and `@iptvnator/shared/interfaces`. -- Do not reintroduce legacy bare aliases such as `services`, `components`, or `shared-interfaces`. -- Prefer library public APIs (`src/index.ts`) over deep imports unless a sub-entrypoint is explicitly configured. -- Keep app-only code in `apps/*`; shared behavior belongs in a domain library. - -## Tag Policy - -- Every project should have `scope:*`, `domain:*`, and `type:*` tags. -- Use `type:feature` for route/component orchestration, `type:data-access` for stores/API/persistence, `type:ui` for reusable components, and `type:util` for pure helpers/contracts. -- Keep `type:data-access` from depending on `type:feature` or `type:ui`. -- Add or update `@nx/enforce-module-boundaries` constraints when introducing new tag families. - -## Validation +In a fresh worktree, install first: ```bash +pnpm install --frozen-lockfile pnpm nx show projects -pnpm nx lint -pnpm nx test +pnpm nx show project +pnpm nx show projects --withTarget test +pnpm nx show projects --withTarget e2e ``` -When dependencies are not installed in a fresh worktree, run `pnpm install --frozen-lockfile` first. +Discovery is authoritative. The current app groups are `web`, +`electron-backend`, `web-backend`, `remote-control-web`, `website`, `web-e2e`, +`electron-backend-e2e`, `stalker-mock-server`, and `xtream-mock-server`. Current +tool projects are `eslint-tools`, `packaging`, `release-tools`, and +`repository-skills`. + +## Place Code by Ownership + +- `apps/`: runtime, development, E2E, and mock-server applications. +- `tools/`: repository automation; tag its projects `scope:tools`. +- `libs/`: domain libraries. +- `type:feature`: route and screen orchestration. +- `type:ui`: reusable visual components. +- `type:data-access`: injectable state, API, persistence, or orchestration. +- `type:util`: the destination for **new pure** helpers and contracts only. + +Provider-neutral collection services that coordinate persistence belong in +`libs/portal/shared/data-access`; pure collection helpers belong in +`libs/portal/shared/util`; reusable views belong in +`libs/portal/shared/ui`. Existing injectable/stateful services under a `util` +path are legacy debt, not placement precedent. + +## Preserve Boundaries + +Every project keeps one `scope:*`, `domain:*`, and `type:*` tag. Enforced type +directions are: + +| Source | Allowed dependencies | +| ----------------- | ------------------------------ | +| app, E2E, dev-app | feature, UI, data-access, util | +| website | UI, util | +| feature | feature, UI, data-access, util | +| UI | UI, data-access, util | +| data-access | data-access, util | +| util | util | + +Domain constraints are additive. Never weaken either constraint to solve a +placement problem. Preserve the documented `workspace-shell-util` path/tag +exception: its injectable services require `type:data-access` so app routes can +eagerly import them without loading the lazy shell feature. + +Use aliases from `tsconfig.base.json` and public `src/index.ts` barrels. Do not +add legacy bare aliases or deep imports. For a buildable library that has a +local `package.json`, its package name must match its scoped alias. + +## Validate the Change + +Production TypeScript targets under 300 lines; 400 is the hard limit. Tests are +limited to 1200. The legacy baseline may only shrink. See +`tools/eslint/max-lines-config.mjs` and regenerate after a split with +`tools/eslint/generate-max-lines-baseline.mjs`; never add a new baseline entry. + +Quote recursive globs in command-based lint targets, for example +`eslint "apps//**/*.ts"`, then compare coverage with +`find apps/ -name '*.ts' | wc -l`. + +Discover targets, then run affected lint/test/build targets and the closest +available E2E target; do not invent targets. Canonical guidance: +`docs/architecture/nx-workspace-boundaries.md`. diff --git a/.codex/skills/iptvnator-sqlite-db-worker/SKILL.md b/.codex/skills/iptvnator-sqlite-db-worker/SKILL.md index 49bf8a163..0a02dffa6 100644 --- a/.codex/skills/iptvnator-sqlite-db-worker/SKILL.md +++ b/.codex/skills/iptvnator-sqlite-db-worker/SKILL.md @@ -1,31 +1,69 @@ --- name: iptvnator-sqlite-db-worker -description: Repository-specific guidance for Electron non-EPG SQLite worker boundaries, request-scoped DB progress events, and validation for slow DB operations. +description: Use when changing Electron SQLite IPC, database-worker operations, request-scoped progress or cancellation, worker packaging, or runtime verification of non-EPG database work. --- # IPTVnator SQLite DB Worker -Use this skill when changing Electron SQLite operations, worker-backed database flows, DB progress events, or Xtream/playlist import/search/delete persistence. +## Ownership and Flow -## Ownership +- Renderer service: `libs/services/src/lib/database-electron.service.ts` +- Preload API: `apps/electron-backend/src/app/api/main.preload.ts` +- IPC handlers: `apps/electron-backend/src/app/events/database/` +- Client: `apps/electron-backend/src/app/services/database-worker-client.ts` +- Protocol: `apps/electron-backend/src/app/workers/database-worker.types.ts` +- Thin dispatcher: `apps/electron-backend/src/app/workers/database.worker.ts` +- Connection: `apps/electron-backend/src/app/workers/database.worker-connection.ts` +- Runtime paths: `apps/electron-backend/src/app/workers/worker-runtime-paths.ts` +- SQL operations: `apps/electron-backend/src/app/database/operations/` +- Shared schema: `libs/shared/database/src/lib/schema.ts` +- Bundler: `apps/electron-backend/build-worker.js` +- Canonical guide: `docs/architecture/sqlite-db-worker.md` -- Runtime worker client: `apps/electron-backend/src/app/services/database-worker-client.ts` -- Worker protocol: `apps/electron-backend/src/app/workers/database-worker.types.ts` -- Worker dispatcher: `apps/electron-backend/src/app/workers/database.worker.ts` -- SQL operation modules: `apps/electron-backend/src/app/database/operations/` -- Shared schema/path helpers: `libs/shared/database/src/` -- Architecture doc: `docs/architecture/sqlite-db-worker.md` +The chain is renderer `DatabaseService` → preload IPC → main event handler → +`DatabaseWorkerClient` and its request protocol → worker dispatcher → +worker connection → operation module → shared +`@iptvnator/shared/database/schema`, then response or request-scoped event back +to the originating renderer. Keep SQL-heavy logic in operation modules and the +worker entrypoint focused on dispatch/orchestration. -## Rules +The build script produces three bundles: EPG parser, database, and playlist +refresh. EPG parsing stays in its dedicated worker. Do not silently migrate +lightweight download handlers or EPG-specific main-process query/mapping/fetch +handlers as part of unrelated database work. -- Keep heavy non-EPG SQLite work off the Electron main thread. -- Keep SQL-heavy logic in operation modules; keep the worker entrypoint as dispatcher/orchestration. -- Emit request-scoped `DB_OPERATION_EVENT` progress for long-running operations. -- Preserve cancellation as cooperative and chunk-based. -- Do not break existing preload API method names without a coordinated renderer migration. +## Identity, Progress, and Cancellation -## Validation +`requestId` is generated for every client request and correlates worker +event/response transport. `operationId` is the renderer-visible identity for +long-operation progress and cooperative cancellation. -- Run `pnpm nx test electron-backend` for worker/client/operation changes. -- Run targeted Electron E2E for import, search, delete, backup/restore, or downloads flows when touched. -- Use `IPTVNATOR_TRACE_DB=1` or `IPTVNATOR_TRACE_SQL=1` for manual debugging. +Tracked operations are save content, delete Xtream content, restore Xtream user +data, delete playlist, and delete all playlists. The first four are +cancellable. Delete-all is deliberately tracked with `cancellable: false`. + +Cancellation is cooperative at chunk checkpoints. Committed chunks remain +committed and the request finally rejects with `AbortError`. For the +operation/busy lifecycle, only a terminal completed/error/cancelled event +settles UI state; a cancel-requested flag may update immediately. + +Inside synchronous `better-sqlite3` transactions, prepared writes must use +`.run()`. `.execute()` defers work and can commit a silent no-op. + +## Rebuild and Verify + +After worker source changes: + +```bash +pnpm nx test electron-backend +pnpm nx run electron-backend:build-worker +stat dist/apps/electron-backend/workers/database.worker.js +``` + +Confirm the artifact timestamp, restart Electron, then run the closest +Electron E2E or CDP workflow. + +For SQL output, `IPTVNATOR_TRACE_DB=1` and `IPTVNATOR_TRACE_SQL=1` emit only +fixed, allowlisted statement types through the shared redacting summary in +`libs/shared/logging/src/lib/sql-trace-summary.ts`; never log expanded SQL or +bound values. DB transport traces remain separately redacted. diff --git a/.codex/skills/iptvnator-theme-style/SKILL.md b/.codex/skills/iptvnator-theme-style/SKILL.md index 0763e7c8e..d986bcc45 100644 --- a/.codex/skills/iptvnator-theme-style/SKILL.md +++ b/.codex/skills/iptvnator-theme-style/SKILL.md @@ -1,31 +1,52 @@ --- name: iptvnator-theme-style -description: Theme architecture, design tokens, shared SCSS library, portal header/sidebar patterns, Electron drag regions, and cross-portal style consistency. +description: Use when changing IPTVnator SCSS tokens, shared layout mixins, portal headers, sidebars, detail views, Electron drag regions, or cross-portal visual consistency. --- # IPTVnator Theme Style -Use this skill when changing SCSS tokens, shared layout mixins, portal headers, sidebars, detail views, or Electron draggable regions. +## Canonical Sources -## Shared Style Sources +- Theme contexts and app tokens: `apps/web/src/m3-theme.scss` +- Shared forwarding inventory: `libs/ui/styles/_index.scss` +- Shared partials: `libs/ui/styles/_portal-layout.scss`, + `libs/ui/styles/_content-grid.scss`, `libs/ui/styles/_portal-sidebar.scss`, + `libs/ui/styles/_panel-header.scss`, `libs/ui/styles/_detail-view.scss`, and + `libs/ui/styles/_detail-view-actions.scss` +- UI policy and migration debt: `docs/architecture/iptvnator-ui-guidelines.md` -- `libs/ui/styles/_index.scss` -- `libs/ui/styles/_portal-layout.scss` -- `libs/ui/styles/_content-grid.scss` -- `libs/ui/styles/_portal-sidebar.scss` -- `libs/ui/styles/_panel-header.scss` -- `libs/ui/styles/_detail-view-actions.scss` -- `docs/architecture/iptvnator-ui-guidelines.md` +The index is a barrel, not a configured Sass include path. Production +consumers currently use relative `@use` paths to the needed partial. -## Rules +## Token Boundary -- Prefer shared SCSS mixins and Material system tokens over local hard-coded colors. -- Keep Electron drag behavior explicit: interactive controls need `app-region: no-drag`. -- Do not duplicate large SCSS files across feature libraries; extract a shared partial or mixin. -- Use relative SCSS imports only when no stable shared entrypoint exists. -- Check Xtream, Stalker, M3U, and workspace views for cross-portal style drift when editing shared patterns. +- App-owned surfaces, text, separators, hover states, selections, and provider + accents use `--app-*` tokens from `m3-theme.scss`. +- Use `--app-selection-on-color` for foregrounds placed on the selection + accent; do not assume white has enough contrast in both themes. +- Angular Material mixins and Material-component overrides may use Material + tokens. Outside Material-owned components, use a `--mat-sys-*` token only + after proving it is emitted in both light and dark contexts and supplying a + real app-token or literal fallback. +- Local semantic status colors are acceptable. Existing hard-coded layout, + selection, and EPG surface colors are migration debt, not precedent. + +## Shared Layout Rules + +- Extend the matching partial instead of copying a large provider stylesheet. + Provider-neutral services and UI use the existing shared data-access/UI + libraries; repeated visual structure belongs in a shared Sass partial. + Portal-shared consumers use their existing forwarding style modules instead + of copied SCSS. +- In an Electron drag region, every interactive descendant—buttons, links, + inputs, overlays, and resize handles—must explicitly use + `app-region: no-drag`. The shared directive-generated `.resize-handle` does + not set this centrally yet; consumers in drag regions must cover it + themselves and must not assume the generated handle opts out. +- A shared change must be checked across M3U, Xtream, Stalker, workspace, + portal catalog/shared UI, and unified collections where relevant. ## Validation -- Run lint/tests for the affected UI project. -- Manually inspect the changed screen in light and dark themes for layout, selection, and drag-region regressions. +Run the affected consumer's Nx lint/test/build target. Inspect light and dark +themes, selected/hover states, and Electron title-bar drag/no-drag behavior. diff --git a/.codex/skills/iptvnator-ui-design/SKILL.md b/.codex/skills/iptvnator-ui-design/SKILL.md index 6c49bb223..b1be20fbe 100644 --- a/.codex/skills/iptvnator-ui-design/SKILL.md +++ b/.codex/skills/iptvnator-ui-design/SKILL.md @@ -1,29 +1,44 @@ --- name: iptvnator-ui-design -description: Repository-specific UI design guidance for IPTVnator channel rows, EPG views, settings surfaces, shared selection styles, and light/dark theme consistency. +description: Use when changing user-visible Angular UI in IPTVnator, especially channel rows or lists, EPG views, settings and playlist surfaces, selection states, shared portal components, or light/dark styling. --- # IPTVnator UI Design -Use this skill when changing user-visible Angular UI in the IPTVnator app, especially channel lists, EPG panels, settings, playlist surfaces, and portal shared components. +## Inspect First -## Principles +- Policy: `docs/architecture/iptvnator-ui-guidelines.md` +- Theme and navigation: `apps/web/src/m3-theme.scss`, + `apps/web/src/nav-list.scss` +- Channel row: `libs/ui/components/src/lib/channel-list-container/channel-list-item/` +- Shared EPG timeline/list: `libs/ui/epg/src/lib/epg-timeline/`, + `libs/ui/epg/src/lib/epg-list-view/` -- Prefer dense, scannable application UI over marketing-style layouts. -- Preserve existing Material 3 token usage and light/dark theme behavior. -- Keep repeated selection states aligned with existing `--app-selection-*` tokens. -- Use existing shared UI components before adding local one-off markup. -- Avoid large visual rewrites in behavior-focused changes. +Before adding local markup, inspect `@iptvnator/ui/components`, +`@iptvnator/ui/epg`, `@iptvnator/ui/playback`, +`@iptvnator/ui/shared-portals`, `@iptvnator/portal/shared/ui`, and +`@iptvnator/playlist/shared/ui`. Stateful collection loading, persistence, and +cross-provider orchestration belong in `@iptvnator/portal/shared/data-access`, +not UI or util. -## Checklist +## Design Contract -1. Inspect the nearest existing component and shared UI library before editing. -2. Check both light and dark theme styles when touching colors, borders, hover states, or selection states. -3. Keep text and controls within fixed-width rows from resizing the layout. -4. Prefer existing components from `@iptvnator/ui/components`, `@iptvnator/portal/shared/ui`, and `@iptvnator/ui/epg`. -5. Add or update focused component tests for changed interaction states. +- Prefer dense, scannable application UI. Behavior-only work must not include + an opportunistic visual rewrite. +- App chrome uses app-owned selection, surface, and text tokens in both themes. + Do not extend the known hard-coded EPG/surface styling debt. +- Dense rows use minimum dimensions rather than a fixed width: the text column + needs `min-width: 0` plus ellipsis, while logos and trailing actions use + `flex-shrink: 0`. +- Assign one scroll owner per pane. Preserve sticky controls, keyboard/focus + feedback, and loading, empty, error, disabled, hover, and selected states. +- Providers supply controlled data to shared EPG timeline/list/panel components + instead of rebuilding those views. +- Shared changes require checking every affected M3U, Xtream, Stalker, + workspace, and collection consumer in light and dark themes. ## Validation -- Run the affected Nx project test target. -- For visible workflow changes, run the closest Playwright E2E target or manually verify through Electron CDP as documented in `AGENTS.md`. +Run the focused component/unit target and the closest Playwright workflow for a +visible change. Electron CDP is a fallback for Electron-only gaps, not a +replacement for an available E2E flow. Record any uncovered visual state. diff --git a/.codex/skills/release-cut/SKILL.md b/.codex/skills/release-cut/SKILL.md index 62e07edd5..2ff98f9b0 100644 --- a/.codex/skills/release-cut/SKILL.md +++ b/.codex/skills/release-cut/SKILL.md @@ -1,88 +1,74 @@ --- name: release-cut -description: Cut an IPTVnator release — bump the version, generate release notes from .changes/, scaffold the website post, tag, and verify the draft. Use when asked to release, cut a version, prepare release notes, or publish a new version. +description: Use when preparing, cutting, tagging, publishing, or verifying an IPTVnator release or its release assets. --- # Release Cut -The pipeline turns accumulated `.changes/*.md` notes into all three release -surfaces. Order matters: **the tag build extracts the CHANGELOG section into -the GitHub release body and fails if it is missing**, so the changelog step -is not optional. +The tag workflow authors the public GitHub body with +`node tools/release/extract-changelog-section.mjs --public "${VERSION}"`. +Keep the full changelog, including internal notes, committed before tagging. -## Sequence +## Preflight -1. **Pick the version** — deliberate choice, edit `version` in the root - `package.json`. Bare semver only: any suffix flips electron-updater into - prerelease mode and leaks into installer version fields. +Work from clean, current `master` with the intended remote named explicitly. +Confirm `package.json` contains bare semver, the exact `v` tag does not +exist locally or remotely, CI is green, and all notes validate. -2. **Review the notes** — read every file in `.changes/`. Fix wording (user - language, not reviewer language), then: +```bash +pnpm run release:notes:validate +pnpm run i18n:check +``` - ```bash - pnpm run release:notes:validate - ``` +## Generate -3. **Generate the changelog section** (idempotent per version — rerunning - replaces the section, so regenerate freely until it reads well): +1. Set `package.json.version`. +2. Run `pnpm run release:notes:changelog`. +3. Minor release: run `pnpm run release:notes:blog` and finish every editorial + field. Patch release: edit the existing `vX-Y` post; do not scaffold or + force-overwrite it. +4. Capture required manifest screenshots only against mock servers: + `pnpm nx run electron-backend:build-e2e`, then + `pnpm run release:screenshots`. +5. Consume notes only after reviewing all generated output: + `node tools/release/build-release-notes.mjs --consume`. - ```bash - pnpm run release:notes:changelog - ``` +The consume command is the destructive boundary: it deletes the direct note +files. Stage only release-owned files, including exact website post/assets and +`git add -A -- .changes`, then commit and create the exact tag. -4. **Scaffold the website post**: +```bash +git commit -m "chore(release): v0.24.0" +git tag v0.24.0 +``` - ```bash - pnpm run release:notes:blog - ``` +## Push and External Effects - Output is `apps/website/src/content/blog/v0-XX-release-notes.mdx` with - `draft: true`. The narrative intro, headlines, and `description` are - editorial — fill every `TODO` by hand. One post per **minor** version: - for a patch release, edit the existing post (the scaffold refuses to - overwrite without `--force`). +Push the named remote's `master` branch first, then push only the exact +`v` tag as a second command. Never use broad `git push --tags`. +For remote `upstream` and version `v0.25.1`, run exactly: -5. **Screenshots** — only from the fail-closed capture script against the - mock servers, never from a real playlist or account: real streams, logos, - and TMDB artwork are copyrighted, and credentials must never reach a - published image. +```bash +git push upstream master +git push upstream v0.25.1 +``` - ```bash - pnpm nx run electron-backend:build-e2e # once - pnpm run release:screenshots # all manifest shots, dark+light - ``` +Master and `v*` pushes can publish Docker images. The tag build creates a draft +GitHub release. Verify authored text plus generated commits and all required +macOS, Windows, DEB, RPM, Pacman (`.pacman`/`.pkg.tar.*`), AppImage, Snap, +Flatpak, updater metadata, blockmaps, and +`linux-frame-copy-runtime-sources.tar.xz`. - Output goes to `apps/website/public/blog/v0-XX/screenshots/`. New feature - to showcase = new entry in `tools/release/screenshots.manifest.json` - (slug must match the note's `screenshot:` field). The run aborts and - deletes its frames on any guard violation (real-DB touch, external - request, credential-shaped text in frame, TMDB active). +After verification, manually publish the GitHub release. That publication +automatically verifies its Snap assets and uploads them to `edge`. +Installed-Snap smoke and candidate/stable promotion remain manual. Keep the +blog draft during artifact verification; publish it in a follow-up commit and +verify the website deployment. -6. **Consume the notes** (the only destructive step): +## Failure Safety - ```bash - node tools/release/build-release-notes.mjs --consume - ``` +Missing CHANGELOG section: regenerate, commit, delete the bad tag locally and +remotely only after resolving its exact target, then retag. Never publish a +draft until the source archive and Snap contract pass. -7. **Commit, tag, push**: - - ```bash - git add CHANGELOG.md .changes apps/website package.json - git commit -m "chore(release): v0.XX.0" - git tag v0.XX.0 && git push && git push --tags - ``` - -8. **Verify the draft release** once `build-and-make.yaml` finishes: authored - notes on top, GitHub's generated commit list below, all platform assets - present (`.dmg`/`.zip` + `latest-mac.yml`, `.exe`/`.msi` + `latest.yml`, - `.deb`/`.rpm`/`.AppImage`/`.snap`/`.flatpak` + `latest-linux*.yml`, - blockmaps). Publish manually; flip the blog post to `draft: false`. - -## Failure modes - -- **create-release fails with "CHANGELOG.md has no section for X"** — step 3 - was skipped. Run it, commit, delete and re-push the tag. -- **Snap store publication** is a separate manual flow after the public - release exists (`publish-snap.yaml`). -- Post-release checklist candidates: i18n drift (`pnpm run i18n:check`), - update the website `v0-XX` blog assets, announce in Telegram. +The `.codex` and `.claude` copies of this skill must remain byte-identical. diff --git a/.codex/skills/release-notes/SKILL.md b/.codex/skills/release-notes/SKILL.md index 7ccb1338d..308456234 100644 --- a/.codex/skills/release-notes/SKILL.md +++ b/.codex/skills/release-notes/SKILL.md @@ -1,70 +1,49 @@ --- name: release-notes -description: Write the .changes/ release note that every PR with a user-visible change must include. Use when creating or finishing a PR that changes behavior in apps/ or libs/, when the "Release note gate" CI check fails, or when deciding whether the no-release-note label applies. +description: "Use when a change may need a .changes note, the Release note gate fails, or deciding whether type: internal or no-release-note applies." --- -# Release Notes (`.changes/`) +# Release Notes -Every PR with a user-visible change adds **one** note file under `.changes/`. -At release time the notes become the GitHub release body, the `CHANGELOG.md` -section, and the website blog scaffold. CI enforces this: the **Release note -gate** check fails any PR that touches runtime code under `apps/` or `libs/` -without an added `.changes/*.md` file or the `no-release-note` label. +Every user-visible change gets one direct `.changes/-.md` file. The +area matches the conventional-commit scope; the body is present tense, user +language, one to three sentences, and at most 400 characters. -## File format - -Name: `.changes/-.md` — `area` matches the -conventional-commit scope of the PR. +## Format ```markdown --- -type: feature -area: playback -issues: [1187] -screenshot: up-next-rail +type: fix +area: stalker +issues: [1234] +screenshot: optional-manifest-slug --- -Series now show an "Up Next" rail beside the player on wide windows: the rest -of the current season, watch progress, and click-to-play inline. +Stalker series now resume the correct episode. ``` -| Field | Required | Value | -| ------------ | -------- | ---------------------------------------------------- | -| `type` | yes | `breaking` / `feature` / `fix` / `perf` / `internal` | -| `area` | yes | lowercase slug = conventional-commit scope | -| `issues` | no | `[1187]` or bare `1187` — issues this PR closes | -| `screenshot` | no | slug from the release screenshot manifest | +`type` is `breaking`, `feature`, `fix`, `perf`, or `internal`. Omit optional +fields instead of inventing values. Never add a version or PR number. -- **No version field.** The release version is chosen at release time. -- **Never write a PR number.** The generator resolves it from git. -- Unknown keys fail validation — this is what catches typos like `scopr:`. +`internal` records invisible maintenance. It stays collapsed in `CHANGELOG.md` +but is omitted from the blog and the authored public GitHub body. GitHub's +generated commit list may still mention the underlying commits. -## Writing the body +## Skip or Label -One to three sentences, present tense, max 400 characters, **written for a -user, not a reviewer**: +The gate auto-exempts website, E2E and mock-server apps, `*.spec.{js,ts}`, +`*.e2e.{js,ts}`, snapshots, any `/testing/` path, and Markdown. Other test-only, +docs, CI, workflow, or pure-refactor PRs use `no-release-note` when the gate +would otherwise require a note. At least one newly added direct +`.changes/*.md` file satisfies the gate. -- ❌ "Refactor `WebVideoControlsAdapter` to hoist volume state" -- ✅ "The player now remembers volume between episodes" -- ❌ "Fix off-by-one in `resolveEnrichmentSeasonNumber`" -- ✅ "Series with a season marker in the title no longer show the wrong season" - -`type: internal` is for changes worth recording but invisible to users -(dependency bumps with behavior risk, packaging moves). They are excluded -from the release body and blog, and collapsed in `CHANGELOG.md`. - -## When to skip (`no-release-note` label) - -Test-only changes, docs, CI/workflow plumbing, pure refactors with no -behavior change. The gate auto-exempts `*.spec.ts`, `*.e2e.ts`, -`__snapshots__/`, `apps/website/`, `apps/*-e2e/`, `apps/*-mock-server/`, -`libs/shared/testing/` and `*.md` — if only those changed, no label needed. - -## Verify before finishing +## Verify ```bash pnpm run release:notes:validate ``` -Full format reference: `.changes/README.md`. Gate policy: +Full format: `.changes/README.md`. Gate policy: `tools/release/check-release-note-gate.mjs`. + +The `.codex` and `.claude` copies of this skill must remain byte-identical. diff --git a/.codex/skills/stalker-portal/SKILL.md b/.codex/skills/stalker-portal/SKILL.md index d59fa7a20..832b833b7 100644 --- a/.codex/skills/stalker-portal/SKILL.md +++ b/.codex/skills/stalker-portal/SKILL.md @@ -1,66 +1,73 @@ --- name: stalker-portal -description: Repository guidance for Stalker/Ministra portal catalogs, VOD/series shapes, playback metadata, collections, EPG, and remote control. +description: Use when changing Stalker or Ministra routes, stores, catalog or series shapes, playback progress, favorites and recent items, EPG, or remote control. --- # Stalker Portal -Use this skill when changing Stalker/Ministra routes, stores, catalog/detail -views, playback, favorites/recent activity, EPG, or remote control. - ## Read First - `docs/architecture/stalker-portal.md` -- `docs/architecture/stalker-epg.md` for ITV EPG work -- `docs/architecture/remote-control.md` for live remote-control work +- `docs/architecture/stalker-epg.md` for ITV EPG +- `docs/architecture/remote-control.md` for live remote control ## Ownership -- Feature UI: `libs/portal/stalker/feature/src/lib/` -- Store/API data access: `libs/portal/stalker/data-access/src/lib/` -- Electron requests: `apps/electron-backend/src/app/events/stalker.events.ts` -- Shared Stalker item normalization: - `libs/shared/interfaces/src/lib/stalker-item.normalizer.ts` -- Dashboard aggregation: `libs/workspace/dashboard/data-access/src/lib/` +- Routed UI: `libs/portal/stalker/feature/src/lib/` +- API, session, store, and normalization: + `libs/portal/stalker/data-access/src/lib/` +- Electron transport: `apps/electron-backend/src/app/events/stalker.events.ts` +- Provider-neutral collections: `libs/portal/shared/data-access/src/lib/` -Keep provider-specific API and normalization behavior in Stalker data access. -Keep shared portal layouts/utilities provider-neutral. Preserve full-portal -session auth and simple IPC request paths. +Keep Stalker request and shape rules in Stalker data access. Shared portal UI +must remain provider-neutral. -## `is_series` Cross-Surface Checklist +## Series Contract -Treat VOD items with `is_series` as series across every downstream surface. -Do not stop after making the detail view render. +Inside Stalker portal code, `isStalkerSeriesFlag()` is the canonical predicate +and accepts exactly `true`, `1`, and `'1'`. `normalizeStalkerSeriesFlag()` +delegates to it and produces the normalized positive marker `true` or +`undefined`. The activity normalizer in shared interfaces keeps its +dependency-neutral equivalent for favorites/recent and dashboard +classification. Preserve all three modes: regular `/series`, VOD with embedded +`series[]`, and lazy Ministra VOD `is_series`. -1. Accept portal flags `true`, `1`, and `'1'` through the existing normalizers. - Preserve all three modes: regular `/series`, embedded VOD `series[]`, and - lazy Ministra VOD `is_series`. -2. Build quick-start state through the shared series utility. Preserve - `labelKey`, `labelParams`, and `episodeLabel` when adapting it for Stalker; - translation parameters must reach the template. -3. Preserve `is_series` and the VOD origin in favorites/recent activity. - `extractStalkerItemType()` must normalize that activity to dashboard type - `series`. -4. Before either inline or external episode playback, persist the parent - `seriesXtreamId` plus resolved `seasonNumber` and `episodeNumber`. Keep - generated episode tracking IDs stable for lazy `is_series` episodes. When - `season_number` is absent, derive the coordinate from the same naturally - ordered season list used by quick start; do not default every season to 1. -5. The dashboard reads saved playback positions; it must not infer episode - numbers from provider payloads. Legacy rows without season/episode metadata - remain badge-less until that episode is played again. +Favorites/recent preserve the normalized positive marker and VOD origin so +reopening still uses the correct lazy or embedded mode. Keep quick-start +translation parameters and the naturally ordered season fallback when +`season_number` is absent. -## Regression Coverage +Lazy episodes use a deterministic tracking ID scoped by parent series, +provider episode, season key, and episode number. `legacyTrackingId` is only a +guarded compatibility alias. Reconciliation is limited to the current parent +series and optional matching season/episode metadata; an exact scoped row +always wins the resolved display position, while a compatible legacy row may +remain tracked only for cleanup. The scoped ID is the in-memory key. At the +strict migration boundary, save the scoped row before clearing a confirmed +legacy row, and keep legacy progress when the save fails. -- Series view/UI and playback handoff: - `pnpm nx test portal-stalker-feature` -- Stalker shape/store behavior: - `pnpm nx test portal-stalker-data-access` -- Dashboard classification and position lookup: - `pnpm nx test workspace-dashboard-data-access` -- Dashboard badge rendering when changed: - `pnpm nx test workspace-dashboard-feature` +Before inline or external handoff, attach parent `seriesXtreamId` and resolved +season/episode numbers. Keep them on subsequent position writes. -For user-visible workflow changes, run the closest available E2E target. If no -fixture covers the affected portal shape, record that gap and perform the -strongest targeted unit/build validation available. +## Live Contract + +- Start bulk ITV EPG eagerly once channel rows exist. Rows read the bulk cache; + only the active channel may fall back to `get_short_epg`. +- Radio skips EPG and external players, preserves live collection identity + with `radio: 'true'`, and uses the shared inline audio player. + +## Validation + +Run: + +- `pnpm nx test shared-interfaces` +- `pnpm nx test portal-stalker-data-access` +- `pnpm nx test portal-stalker-feature` +- the affected `portal-shared-data-access` / `portal-shared-ui` target for + collection or radio behavior +- the affected `workspace-dashboard-data-access` and + `workspace-dashboard-feature` test targets + +For the user workflow, run +`pnpm nx run web-e2e:e2e-ci--src/stalker.e2e.ts` or document the missing +fixture and strongest focused coverage. diff --git a/.codex/skills/xtream-electron/SKILL.md b/.codex/skills/xtream-electron/SKILL.md index 305afcd27..a89cb0540 100644 --- a/.codex/skills/xtream-electron/SKILL.md +++ b/.codex/skills/xtream-electron/SKILL.md @@ -1,30 +1,64 @@ --- name: xtream-electron -description: IPTVnator's Electron-first Xtream implementation, including feature/data-access boundaries, worker-backed DB flows, and Xtream loading/progress UX. +description: Use when changing Xtream routes, Signal Store or data sources, content identity, SQLite-backed import, search or delete, sparse VOD playback, or Electron and PWA behavior. --- # Xtream Electron -Use this skill when working on Xtream routes, stores, data sources, import/search/delete behavior, or Electron-backed Xtream playback and persistence. +## Read First -## Key Areas +- `docs/architecture/xtream-portal-compatibility.md` +- `docs/architecture/portal-detail-navigation.md` +- `docs/architecture/sqlite-db-worker.md` +- `docs/architecture/vod-multi-source.md` -- Feature UI: `libs/portal/xtream/feature/src/lib/` -- Data access: `libs/portal/xtream/data-access/src/lib/` -- Shared portal utilities: `libs/portal/shared/util/src/lib/` -- Shared portal UI: `libs/portal/shared/ui/src/lib/` -- Electron DB events: `apps/electron-backend/src/app/events/database/` -- DB worker operations: `apps/electron-backend/src/app/database/operations/` +## Ownership And Runtime -## Rules +Routed screens live in `libs/portal/xtream/feature`; API, cache, Signal Store, +and data sources in `libs/portal/xtream/data-access`; collection services and +reusable multi-source discovery/resolution in +`libs/portal/shared/data-access`; reusable views in +`libs/portal/shared/ui`; pure contracts/helpers only in +`libs/portal/shared/util`. Screen-session multi-source orchestration stays in +the Xtream feature. -- Keep provider-specific API/cache behavior in `portal/xtream/data-access`. -- Keep reusable layout and collection UI in `portal/shared/ui` or `portal/shared/util`. -- Prefer worker-backed DB operations for large imports, global search, delete, and restore. -- Preserve request cancellation and progress reporting for long imports. -- Validate both PWA and Electron data-source paths when changing Xtream APIs. +Select `ElectronXtreamDataSource` only through +`RuntimeCapabilitiesService.supportsXtreamSqliteDataSource`; otherwise use the +PWA data source. A generic `window.electron` check is not the capability +contract; existing favorites/recent branches that still use one are migration +debt, not precedent. + +## Data And Navigation Contracts + +- Xtream identity is playlist + normalized content type + provider + `xtream_id`; collection keys include type and ID. Never resolve colliding + live/movie/series IDs by number alone. Distinguish SQLite row IDs from + provider `xtream_id` / `stream_id` / `series_id`. +- Browse and search use canonical item routes. Favorites/recent use + collection-owned inline detail. Preserve enough provider/route identity to + recover hidden categories without sending a local database ID to the API. +- Sparse VOD publishes the fallback selection first and recovers its provider + category in the background. A playable source is one complete positive + stream-ID + extension pair; never combine fields from incomplete candidates. +- VOD multi-source is capability-gated, owner-scoped, Electron-only + Xtream-to-Xtream movie behavior. Its reusable core belongs in portal shared + data access; the screen session/host belongs in the Xtream feature. + +`XtreamStore` composes portal, content, selection, search, EPG, player, +favorites, recent items, and playback positions. + +On the SQLite path, large import/search/delete/restore work remains +worker-backed. Renderer-visible DB progress and cancellation use +`operationId`. Xtream API requests use `requestId`, import sessions use +`sessionId`, and the DB worker also has an internal transport request ID; never +conflate any of them. ## Validation -- Run `pnpm nx test portal-xtream-data-access` and `pnpm nx test portal-xtream-feature` for store/UI changes. -- Run `pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts` or the closest Electron E2E target for user-visible Xtream workflow changes. +Run both data-source specs plus affected Xtream store/feature tests. Use the +closest atomized flow: + +- `web-e2e:e2e-ci--src/xtream.e2e.ts` +- `electron-backend-e2e:e2e-ci--src/xtream-responsiveness.e2e.ts` +- `electron-backend-e2e:e2e-ci--src/xtream-vod-details.e2e.ts` +- `electron-backend-e2e:e2e-ci--src/vod-multi-source.e2e.ts` diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 43158d2d8..3638e3359 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -1450,15 +1450,12 @@ jobs: if [ "${IS_TAG_BUILD}" = "true" ]; then NAME="Release v${VERSION}" TAG="${GITHUB_REF_NAME}" - # Authored release notes: the release flow writes this - # CHANGELOG section from .changes/*.md before tagging - # (see .changes/README.md), so at tag time the changelog - # is the authored source of truth. The extractor exits - # non-zero when the section is missing, failing the - # release rather than silently shipping PR-title-only - # notes. generate_release_notes stays on below, so the - # GitHub commit list still renders under this body. - BODY="$(node tools/release/extract-changelog-section.mjs "${VERSION}")" + # The full committed CHANGELOG retains internal notes, + # while the tag release uses authored public extraction. + # An internal-only release intentionally has no authored + # public text. GitHub's generated commit list remains + # separate below. + BODY="$(node tools/release/extract-changelog-section.mjs --public "${VERSION}")" elif [ "${EVENT_NAME}" = "pull_request" ]; then NAME="v${VERSION} — PR #${PR_NUMBER} @ ${SHORT_SHA} [test]" TAG="test-pr-${PR_NUMBER}" diff --git a/AGENTS.md b/AGENTS.md index 4fa10bf23..d27ab57a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,13 @@ This file provides guidance to coding agents working in this repository. - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. - ESLint enforces `max-lines` on TypeScript files: production code targets under 300 with a hard maximum of 400, while tests (`**/*.spec.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 — a long spec signals coverage, not the design debt the production limit catches. Blank lines and comments are not counted, so a docblock never forces a split. Limits live in `tools/eslint/max-lines-config.mjs`, imported by both `eslint.config.mjs` and the generator so the rule and the baseline cannot drift. Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs` (it runs ESLint's own rule rather than counting lines itself). Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. Remove such a directive once ESLint reports it as unused. - Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint "apps//**/*.ts"`. An unquoted `**` is expanded by the POSIX shell on Linux and macOS (which has no `globstar`, so it matches only a shallow subset of files) while Windows passes the literal pattern to ESLint, which expands it recursively — the two hosts then lint different file sets. The target still reports success either way, so a broken glob hides missing coverage instead of failing. After changing such a target, compare the linted file count against `find -name '*.ts' | wc -l`. -- Repository-specific skills are committed under `.codex/skills/`. Claude Code only discovers skills under `.claude/skills/`, so `release-notes` and `release-cut` are mirrored there and the two copies must be kept in sync; every other entry in `.claude/skills/` is personal and stays gitignored. If an external agent does not support skills, treat those files as concise ownership docs. +- Repository-specific skills live under `.codex/skills/`. +- Frontmatter descriptions are trigger-only and begin with `Use when`; keep + each skill at or below 500 words. +- Run `pnpm run skills:validate` after editing a committed skill or a literal + path it documents. +- Keep `.codex` and `.claude` copies of `release-notes` and `release-cut` + byte-identical. ## Documentation After Changes @@ -40,10 +46,13 @@ This file provides guidance to coding agents working in this repository. - Any change a user could notice — new behavior, changed behavior, bug fix, performance win, breaking change — must add one note file under `.changes/` in the same PR. Format, field table, and writing rules: `.changes/README.md`. - Name it `-.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time. - Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post. +- `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body. - Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label. - CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt). - The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. - Validate before finishing: `pnpm run release:notes:validate`. +- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release. +- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual. - Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. - Final task summaries should state whether a release note was added or why it was skipped. @@ -407,35 +416,17 @@ Key files: ## Repo Skills -- `iptvnator-ui-design` - Repository-specific UI design guidance for IPTVnator. - Use when working on channel rows, EPG views, settings surfaces, shared selection styles, or light/dark theme consistency. - File: `.codex/skills/iptvnator-ui-design/SKILL.md` +- `.codex/skills/iptvnator-nx-architecture/SKILL.md` +- `.codex/skills/iptvnator-sqlite-db-worker/SKILL.md` +- `.codex/skills/iptvnator-theme-style/SKILL.md` +- `.codex/skills/iptvnator-ui-design/SKILL.md` +- `.codex/skills/release-cut/SKILL.md` +- `.codex/skills/release-notes/SKILL.md` +- `.codex/skills/stalker-portal/SKILL.md` +- `.codex/skills/xtream-electron/SKILL.md` -- `iptvnator-theme-style` - Theme architecture, design token reference, shared SCSS library, portal header pattern, Electron drag region, and common styling mistakes. - Use when adding/changing CSS tokens, styling portal headers or sidebars, using shared SCSS mixins (`portal-layout`, `content-grid`, `portal-sidebar`), or auditing cross-portal visual consistency. - File: `.codex/skills/iptvnator-theme-style/SKILL.md` - -- `iptvnator-nx-architecture` - Repository-specific Nx monorepo structure, library placement rules, path alias guidance, and migration guardrails for portal/workspace/app code. - Use when deciding where code belongs, extracting code into libs, choosing imports, or refactoring Xtream/Stalker/Workspace boundaries. - File: `.codex/skills/iptvnator-nx-architecture/SKILL.md` - -- `iptvnator-sqlite-db-worker` - Repository-specific guidance for the Electron non-EPG SQLite worker, including worker boundaries, request-scoped DB progress events, and validation steps for slow DB operations. - Use when moving heavy database work off the main thread, adding worker-backed SQLite operations, or wiring loading/progress UI for Xtream and playlist DB flows. - File: `.codex/skills/iptvnator-sqlite-db-worker/SKILL.md` - -- `stalker-portal` - Repository-specific guidance for Stalker/Ministra catalogs, all three VOD/series modes, cross-surface `is_series` behavior, playback metadata, collections, EPG, and remote control. - Use when changing Stalker routes, stores, detail views, playback, favorites/recent activity, EPG, or remote control. - File: `.codex/skills/stalker-portal/SKILL.md` - -- `xtream-electron` - Repository-specific guidance for IPTVnator's Electron-first Xtream implementation, including feature/data-access boundaries, worker-backed DB flows, and Xtream loading/progress UX expectations. - Use when working on Xtream routes, store/data-source logic, or Electron-backed Xtream import/search/delete behavior. - File: `.codex/skills/xtream-electron/SKILL.md` +Descriptions and trigger conditions are canonical in each skill's frontmatter; +do not duplicate them here. diff --git a/CLAUDE.md b/CLAUDE.md index 5090c4a66..534b5bac8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,10 +31,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Any change a user could notice — new behavior, changed behavior, bug fix, performance win, breaking change — must add one note file under `.changes/` in the same PR. Format, field table, and writing rules: `.changes/README.md`. - Name it `-.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time. - Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post. +- `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body. - Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label. - CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt). - The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. - Validate before finishing: `pnpm run release:notes:validate`. +- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release. +- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual. - Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. - Final task summaries should state whether a release note was added or why it was skipped. @@ -71,7 +74,13 @@ pnpm nx show projects - Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`. - Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json`. - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. -- Repository-specific skills are committed under `.codex/skills/`. Claude Code only discovers skills under `.claude/skills/`, so `release-notes` and `release-cut` are mirrored there and the two copies must be kept in sync; every other entry in `.claude/skills/` is personal and stays gitignored. If an agent does not load skills directly, treat those files as concise ownership docs. +- Repository-specific skills live under `.codex/skills/`. +- Frontmatter descriptions are trigger-only and begin with `Use when`; keep + each skill at or below 500 words. +- Run `pnpm run skills:validate` after editing a committed skill or a literal + path it documents. +- Keep `.codex` and `.claude` copies of `release-notes` and `release-cut` + byte-identical. ### Building and Serving @@ -280,7 +289,7 @@ This is an Nx monorepo with the following structure: - **portal/stalker/{data-access,feature}** - StalkerStore and routed Stalker components - **portal/catalog/feature** - Portal catalog UI - **portal/downloads/feature** - Download manager UI - - **portal/shared/{data-access,ui,util}** - Cross-portal shared code (incl. the VOD multi-source discovery/resolve/ranking layer in `data-access/src/lib/multi-source/`) + - **portal/shared/{data-access,ui,util}** - Cross-portal shared code: stateful collection services and VOD multi-source discovery/resolve/ranking live in `data-access`; reusable views live in `ui`; `util` is for pure contracts/helpers - **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`) - **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`) - **shared/logging** - Dependency-free structured redaction for diagnostic logs @@ -323,14 +332,14 @@ The Xtream Codes module uses NgRx Signal Store with a layered architecture: │ (Composes feature stores, unified API) │ └─────────────────────────────────────────────────────────────────┘ │ - ┌────────────┬────────────┼────────────┬────────────┐ - ▼ ▼ ▼ ▼ ▼ -┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ -│ withPortal│ │withContent │ │withSelection│ │ withSearch │ │ withPlayer │ -└────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘ - │ │ │ - └───────────────────────────┼──────────────┘ - ▼ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ withPortal · withContent · withSelection · withSearch · withEpg │ +│ withPlayer · withFavorites · withRecentItems │ +│ withPlaybackPositions │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ ┌─────────────────────────────────────────────────────────────────┐ │ DATA SOURCE LAYER │ │ IXtreamDataSource │ @@ -382,15 +391,19 @@ Key patterns: - **Feature stores**: Each `with*.feature.ts` uses `signalStoreFeature()` for focused functionality - **Facade pattern**: `XtreamStore` composes all features, maintaining backward compatibility -- **Data source abstraction**: `IXtreamDataSource` interface with environment-specific implementations -- **Factory injection**: `provideXtreamDataSource()` selects Electron or PWA implementation at runtime +- **Data source abstraction**: `IXtreamDataSource` has SQLite-backed and + API/in-memory implementations +- **Factory injection**: `provideXtreamDataSource()` selects + `ElectronXtreamDataSource` only when + `RuntimeCapabilitiesService.supportsXtreamSqliteDataSource`; otherwise it + selects `PwaXtreamDataSource` -Data strategies by environment: +Xtream data strategies by runtime capability: -| Environment | Strategy | -| ------------ | ------------------------------------------------------- | -| **Electron** | DB-first: Check DB → fetch API if missing → cache to DB | -| **PWA** | API-only: Always fetch from API, store in memory | +| Capability | Strategy | +| ---------- | -------- | +| **Complete Xtream SQLite bridge** | DB-first: check DB → fetch API if missing → cache to DB | +| **Bridge unavailable** | API-only: fetch from API and keep session data in memory | **M3U Playlist Module Architecture**: @@ -886,7 +899,7 @@ engine` (restart required) or - Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`) - Xtream VOD treats metadata presentation and playability as separate contracts. Empty or sparse `get_vod_info` data keeps the curated fallback detail page, while Play/Resume, Favorite, and Download remain available whenever a positive stream id and non-empty container extension resolve from `movie_data` or the catalog fields. Playback fields are selected as one atomic pair in detail → recovered catalog → owner-valid cached catalog order; incomplete candidates never combine into a synthetic source. In-memory VOD categories/streams carry their owner playlist, and cross-portal Favorites/Recent details ignore arrays from another playlist so colliding Xtream ids cannot inject stale playback or presentation data. When Electron's normalized catalog cache lacks the extension, the detail loader immediately publishes the sparse fallback and ends its loading state, then performs a best-effort category-scoped raw catalog lookup and reactively upgrades the same item with actions on success. It maps the normal SQLite route category through all persisted categories, including hidden ones, while also accepting the provider `xtream_id` carried by cross-portal Similar links; ambiguous numeric matches keep local-id precedence, deduplicate provider candidates, and try the next candidate when the exact VOD is absent. PWA falls back to API categories. It skips that request when existing data is sufficient, never sends an unresolved database id as a provider id, preserves concurrent metadata enrichment, and drops late detail/recovery responses after replacement, playlist reset, or detail teardown. Inline playback moves either detail page into Watch; external MPV/VLC remains in Browse. Unresolvable items expose no actions, and playback/download titles and posters fall back through `info`, `movie_data`, then catalog fields. - 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: 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. This metadata lets the dashboard render the tracked S/E badge for VOD-backed series. Existing playback rows without it remain badge-less until the episode is played again. +- 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) or TMDB (Stalker) - 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. diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index c78ba43f8..6b43a3975 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -321,4 +321,3 @@ type ActiveXtreamRequest = { }; const activeXtreamRequests = new Map(); - diff --git a/apps/electron-backend/src/app/services/debug-trace.spec.ts b/apps/electron-backend/src/app/services/debug-trace.spec.ts index 91da7daed..c9346f2fd 100644 --- a/apps/electron-backend/src/app/services/debug-trace.spec.ts +++ b/apps/electron-backend/src/app/services/debug-trace.spec.ts @@ -1,4 +1,4 @@ -import { trace } from './debug-trace'; +import { trace, traceSqlStatement } from './debug-trace'; describe('debug trace redaction', () => { afterEach(() => { @@ -44,4 +44,25 @@ describe('debug trace redaction', () => { expect(output).toContain('[Redacted]'); expect(output).toContain('get_profile'); }); + + it('records only the statement type for expanded worker SQL', () => { + jest.spyOn(console, 'log').mockImplementation(() => undefined); + const secrets = [ + 'worker-user-secret', + 'worker-password-secret', + 'https://worker-user:worker-password@example.com/live?token=worker-token-secret', + ]; + + traceSqlStatement( + 'sql-worker', + `INSERT INTO playlists (username, password, url) VALUES ('${secrets[0]}', '${secrets[1]}', '${secrets[2]}')` + ); + + const output = (console.log as jest.Mock).mock.calls.flat().join('\n'); + expect(output).toContain('"statementType":"INSERT"'); + expect(output).not.toContain('INSERT INTO'); + for (const secret of secrets) { + expect(output).not.toContain(secret); + } + }); }); diff --git a/apps/electron-backend/src/app/services/debug-trace.ts b/apps/electron-backend/src/app/services/debug-trace.ts index 6b926a6c3..87c08da74 100644 --- a/apps/electron-backend/src/app/services/debug-trace.ts +++ b/apps/electron-backend/src/app/services/debug-trace.ts @@ -1,4 +1,7 @@ -import { redactSensitiveData } from '@iptvnator/shared/logging'; +import { + redactSensitiveData, + summarizeSqlStatementForTrace, +} from '@iptvnator/shared/logging'; const TRACE_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); const TRACE_PREFIX = '[IPTVnator Trace]'; @@ -84,10 +87,6 @@ export function roundTraceDuration(durationMs: number): number { return Math.round(durationMs * 10) / 10; } -export function compactSqlForTrace(sql: string): string { - return truncateString(sql.replace(/\s+/g, ' ').trim()); -} - export function summarizeForTrace(value: unknown, depth = 0): unknown { if ( value == null || @@ -176,3 +175,7 @@ export function trace(scope: string, message: string, payload?: unknown): void { )}` ); } + +export function traceSqlStatement(scope: string, sql: unknown): void { + trace(scope, 'query', summarizeSqlStatementForTrace(sql)); +} diff --git a/apps/electron-backend/src/app/workers/database.worker-connection.ts b/apps/electron-backend/src/app/workers/database.worker-connection.ts index 9bc8b1bb7..122213d7a 100644 --- a/apps/electron-backend/src/app/workers/database.worker-connection.ts +++ b/apps/electron-backend/src/app/workers/database.worker-connection.ts @@ -10,9 +10,9 @@ import { registerNativeModuleSearchPaths, } from './worker-runtime-paths'; import { - compactSqlForTrace, isSqlTraceEnabled, trace, + traceSqlStatement, } from '../services/debug-trace'; let drizzleFactory: @@ -67,11 +67,7 @@ export async function getWorkerDatabase(): Promise { const filePath = getIptvnatorDatabasePath(); sqlite = new Database(filePath, { verbose: isSqlTraceEnabled() - ? (sql: string) => { - trace('sql-worker', 'query', { - sql: compactSqlForTrace(sql), - }); - } + ? (sql: string) => traceSqlStatement('sql-worker', sql) : undefined, }); sqlite.pragma('foreign_keys = ON'); diff --git a/apps/web/src/app/services/portal-playback-positions.service.spec.ts b/apps/web/src/app/services/portal-playback-positions.service.spec.ts new file mode 100644 index 000000000..f6fdf4f0b --- /dev/null +++ b/apps/web/src/app/services/portal-playback-positions.service.spec.ts @@ -0,0 +1,128 @@ +import { + DestroyableInjector, + Injector, + runInInjectionContext, +} from '@angular/core'; +import { + IXtreamDataSource, + XTREAM_DATA_SOURCE, +} from '@iptvnator/portal/xtream/data-access'; +import { + PlaybackPositionRuntimeBridgeService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; +import type { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { AppPortalPlaybackPositionsService } from './portal-playback-positions.service'; + +interface PersistenceMocks { + clearDataSource: jest.Mock; + clearRuntime: jest.Mock; + saveDataSource: jest.Mock; + saveRuntime: jest.Mock; +} + +interface PersistenceHarness { + mocks: PersistenceMocks; + service: AppPortalPlaybackPositionsService; +} + +const position: PlaybackPositionData = { + contentXtreamId: 101, + contentType: 'episode', + playlistId: 'playlist-1', + positionSeconds: 42, +}; + +describe('AppPortalPlaybackPositionsService strict persistence', () => { + let injector: DestroyableInjector; + + function createHarness( + supportsXtreamSqliteDataSource: boolean + ): PersistenceHarness { + const mocks: PersistenceMocks = { + clearDataSource: jest.fn().mockResolvedValue(undefined), + clearRuntime: jest.fn().mockResolvedValue(undefined), + saveDataSource: jest.fn().mockResolvedValue(undefined), + saveRuntime: jest.fn().mockResolvedValue(undefined), + }; + const dataSource = { + clearPlaybackPosition: mocks.clearDataSource, + savePlaybackPosition: mocks.saveDataSource, + } as unknown as IXtreamDataSource; + injector = Injector.create({ + providers: [ + AppPortalPlaybackPositionsService, + { provide: XTREAM_DATA_SOURCE, useValue: dataSource }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsXtreamSqliteDataSource }, + }, + { + provide: PlaybackPositionRuntimeBridgeService, + useValue: { + clearPlaybackPositionOrThrow: mocks.clearRuntime, + savePlaybackPositionOrThrow: mocks.saveRuntime, + }, + }, + ], + }); + return { + mocks, + service: runInInjectionContext(injector, () => + injector.get(AppPortalPlaybackPositionsService) + ), + }; + } + + afterEach(() => injector.destroy()); + + describe.each([ + { + dataSourceMock: (mocks: PersistenceMocks) => + mocks.saveDataSource, + invoke: (service: AppPortalPlaybackPositionsService) => + service.savePlaybackPositionOrThrow( + 'playlist-1', + position + ), + runtimeMock: (mocks: PersistenceMocks) => mocks.saveRuntime, + }, + { + dataSourceMock: (mocks: PersistenceMocks) => + mocks.clearDataSource, + invoke: (service: AppPortalPlaybackPositionsService) => + service.clearPlaybackPositionOrThrow( + 'playlist-1', + 101, + 'episode' + ), + runtimeMock: (mocks: PersistenceMocks) => mocks.clearRuntime, + }, + ])('strict operation %#', (operation) => { + it('propagates Electron bridge rejection', async () => { + const { mocks, service } = createHarness(true); + const error = new Error('Electron write failed'); + operation.runtimeMock(mocks).mockRejectedValue(error); + + await expect(operation.invoke(service)).rejects.toBe(error); + expect(operation.dataSourceMock(mocks)).not.toHaveBeenCalled(); + }); + + it('propagates PWA data-source rejection', async () => { + const { mocks, service } = createHarness(false); + const error = new Error('localStorage quota exceeded'); + operation.dataSourceMock(mocks).mockRejectedValue(error); + + await expect(operation.invoke(service)).rejects.toBe(error); + expect(operation.runtimeMock(mocks)).not.toHaveBeenCalled(); + }); + + it('keeps a partial bridge on the selected PWA data source', async () => { + const { mocks, service } = createHarness(false); + + await expect(operation.invoke(service)).resolves.toBeUndefined(); + expect(operation.dataSourceMock(mocks)).toHaveBeenCalledTimes(1); + expect(operation.runtimeMock(mocks)).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/app/services/portal-playback-positions.service.ts b/apps/web/src/app/services/portal-playback-positions.service.ts index ba987aa98..cb6db69ed 100644 --- a/apps/web/src/app/services/portal-playback-positions.service.ts +++ b/apps/web/src/app/services/portal-playback-positions.service.ts @@ -3,6 +3,10 @@ import { PORTAL_PLAYBACK_POSITIONS, PortalPlaybackPositions, } from '@iptvnator/portal/shared/util'; +import { + PlaybackPositionRuntimeBridgeService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { PlaybackPositionData, XTREAM_DATA_SOURCE, @@ -15,6 +19,12 @@ export class AppPortalPlaybackPositionsService implements PortalPlaybackPositions { private readonly dataSource = inject(XTREAM_DATA_SOURCE); + private readonly useRuntimePersistence = inject( + RuntimeCapabilitiesService + ).supportsXtreamSqliteDataSource; + private readonly runtimeBridge = inject( + PlaybackPositionRuntimeBridgeService + ); async savePlaybackPosition( playlistId: string, @@ -23,6 +33,21 @@ export class AppPortalPlaybackPositionsService await this.dataSource.savePlaybackPosition(playlistId, data); } + async savePlaybackPositionOrThrow( + playlistId: string, + data: PlaybackPositionData + ): Promise { + if (this.useRuntimePersistence) { + await this.runtimeBridge.savePlaybackPositionOrThrow( + playlistId, + data + ); + return; + } + + await this.dataSource.savePlaybackPosition(playlistId, data); + } + async getPlaybackPosition( playlistId: string, contentXtreamId: number, @@ -62,6 +87,27 @@ export class AppPortalPlaybackPositionsService contentType ); } + + async clearPlaybackPositionOrThrow( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): Promise { + if (this.useRuntimePersistence) { + await this.runtimeBridge.clearPlaybackPositionOrThrow( + playlistId, + contentXtreamId, + contentType + ); + return; + } + + await this.dataSource.clearPlaybackPosition( + playlistId, + contentXtreamId, + contentType + ); + } } export const providePortalPlaybackPositions = () => [ diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 519f2f60a..5b9be58a7 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -32,6 +32,10 @@ Use it when changing existing views or introducing new list-based UI in the work `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html` - Shared EPG timeline styles: `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss` +- Shared EPG list: + `libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts` +- Shared EPG list styles: + `libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss` - Shared list selection style: `apps/web/src/nav-list.scss` - Theme tokens: @@ -46,6 +50,7 @@ Use it when changing existing views or introducing new list-based UI in the work These tokens are the base for interactive emphasis: - `--app-selection-color` +- `--app-selection-on-color` - `--app-selection-surface` - `--app-selection-surface-strong` - `--app-selection-border` @@ -61,16 +66,16 @@ in `apps/web/src/m3-theme.scss`): - `--app-on-surface` — primary text - `--app-eyebrow-color` — secondary/muted text -**Do not reach for `--mat-sys-*` surface tokens.** Angular Material's system -tokens are *not* emitted globally in this app — only `--mat-sys-on-surface` -exists. `var(--mat-sys-surface-container-high)` and friends resolve to nothing, -which usually goes unnoticed because the surrounding Material component -supplies its own default and masks it. Outside a Material component — a CDK -overlay, a projected panel — the same reference silently produces a fully -transparent, unreadable surface. +Angular Material mixins and Material-component overrides may use the tokens +owned by that component. Outside a Material-owned component, prefer the +app-owned tokens above. A `--mat-sys-*` reference is acceptable there only +after the built light and dark theme contexts both prove that it is emitted, +and it must still have a real app-token or literal fallback, for example: +`var(--mat-sys-surface-container, var(--app-widget-bg))`. -If a `--mat-sys-*` token is genuinely needed, always give it a real fallback: -`var(--mat-sys-on-primary, #fff)`. +Several existing app surfaces still reference Material system tokens without +that proof or use hard-coded layout/selection colors. Treat those references +as migration debt, not patterns to copy. Do not hardcode unrelated accent colors for selected state when these tokens already exist. @@ -89,6 +94,9 @@ Apply the same visual recipe to selected list items, active channels, and curren - Text: selected text should inherit `var(--app-selection-color)` +Use `var(--app-selection-on-color)` when text or an icon sits directly on a +solid `var(--app-selection-color)` fill. + Use this pattern for: - `.nav-item.selected` / `.nav-item.active` @@ -108,13 +116,21 @@ Do not copy the full detail-view stylesheet into feature libraries. Add shared layout changes to the mixin, and keep provider-specific differences explicit in the wrapper file that includes it. +## Electron Drag Regions + +Every interactive descendant of a drag region—including buttons, links, +inputs, overlays, and resize handles—requires `app-region: no-drag`. The shared +directive-generated `.resize-handle` does not set this centrally yet. Until +that debt is fixed, consumers in drag regions must cover the handle themselves +and must not assume it already opts out. + ## Channel List Item The shared row should be reused instead of rebuilding channel markup per view. -### Structure +### Current Reference Values -- Min height: +- Current minimum height: `68px` - Horizontal gap: `12px` @@ -122,11 +138,16 @@ The shared row should be reused instead of rebuilding channel markup per view. `8px 10px 8px 12px` - Radius: `12px` -- Logo shell: +- Current logo shell: `44x44`, rounded, subtle inset treatment - Compact variant: `52px` min height with slightly tighter padding +These values describe the current shared row, not a fixed-width contract. Keep +the row responsive: the text column uses `min-width: 0` and ellipsis, while +logos, drag affordances, and trailing actions use `flex-shrink: 0`. Prefer +minimum dimensions and flexible columns over fixed row widths. + ### Content Layout - Title is one line, medium-bold, slightly condensed @@ -143,6 +164,12 @@ The shared row should be reused instead of rebuilding channel markup per view. ## EPG Views +The shared timeline and list still contain local dark surfaces, blue selection +accents, and white foregrounds. These non-semantic hard-coded colors are +migration debt. New work should use app surface/selection/text tokens and must +not spread those local fallbacks. Semantic live, error, and status colors may +remain local when the meaning is explicit. + ### Shared EPG Pane - Header title stays sticky @@ -271,7 +298,9 @@ Settings use the same system but are flatter than content-heavy views. ### Light Theme - Prefer white or near-white cards -- Use neutral borders from `--mat-sys-outline-variant` +- Use app-owned neutral borders, or a proven Material token with a real + fallback such as + `var(--mat-sys-outline-variant, var(--app-widget-border))` - Keep active sections mostly defined by outline and subtle tint - Avoid dark translucent backgrounds @@ -286,7 +315,7 @@ Settings use the same system but are flatter than content-heavy views. ### Light Theme - Flat beats glossy -- White and surface-container layers should separate content +- White and app-owned widget/content surface layers should separate content - Selection should read as a blue outline plus soft tint, not a solid slab ### Dark Theme @@ -302,7 +331,14 @@ Before creating new markup or CSS: 1. Check whether `app-channel-list-item` can be reused. 2. Check whether `app-epg-timeline` already provides the correct structure. 3. Check whether `nav-list.scss` already solves the list-selection problem. -4. Extend tokens first, duplicate styles last. +4. Inspect the public APIs of `@iptvnator/ui/components`, + `@iptvnator/ui/epg`, `@iptvnator/ui/playback`, + `@iptvnator/ui/shared-portals`, `@iptvnator/portal/shared/ui`, and + `@iptvnator/playlist/shared/ui`. +5. Put provider-neutral collection loading, persistence, and cross-provider + orchestration in `@iptvnator/portal/shared/data-access`, not a UI library or + the shared util library. +6. Extend tokens first, duplicate styles last. ## Implementation Workflow @@ -312,13 +348,16 @@ When updating IPTVnator UI: 2. Reuse the shared structure where possible. 3. Keep selection, progress, and spacing in sync across Xtream, Stalker, and shared portal views. 4. Verify in both light and dark themes. -5. Verify in the running Electron app when the change is visual or layout-sensitive. +5. Run the focused component/unit target and the closest Playwright workflow. +6. Use the running Electron app through CDP only for Electron-only gaps or + additional layout inspection; it does not replace available E2E coverage. ## Anti-Patterns Avoid these: - introducing a new selected-state color unrelated to the theme tokens +- copying the shared EPG's hard-coded dark/blue fallbacks into new surfaces - duplicating channel row markup in portal-specific views - showing placeholder logos behind real logos - making entire panes scroll when only the list should scroll diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 17c085040..e73a47b54 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -1,100 +1,130 @@ # Nx Workspace Boundaries -This document records the current monorepo boundary conventions for IPTVnator. +This document records the current monorepo placement, tagging, and validation +contract for IPTVnator. Nx discovery is the canonical project inventory; avoid +copying an exhaustive project list into documentation. -## Fresh Worktree Bootstrap +## Fresh Worktree Bootstrap and Discovery -Install dependencies before using Nx discovery or targets: +Install dependencies before relying on Nx: ```bash pnpm install --frozen-lockfile pnpm nx show projects ``` -`pnpm nx show projects` depends on the workspace-local Nx packages under -`node_modules`. In a fresh worktree without dependencies it will fail before it -can inspect project metadata. +`pnpm nx show projects` requires the workspace-local Nx packages in +`node_modules`. Inspect project ownership and available validation targets +before choosing commands: + +```bash +pnpm nx show project +pnpm nx show projects --withTarget test +pnpm nx show projects --withTarget e2e +``` + +Do not invent a `test`, `build`, or `e2e` target because a similarly named +project has one. Run affected lint/test/build targets that exist and the closest +available E2E target for the changed behavior. + +## Placement Decision + +- `apps/` owns runtime applications, development servers, E2E applications, + and provider mock servers. +- `libs/` owns reusable code grouped by product domain and architectural role. +- `tools/` owns repository automation such as lint, packaging, release, and + repository-skill validation. Nx projects there use `scope:tools`. + +Inside `libs/`, choose the role before the path: + +- `type:feature` owns routes, screens, and feature orchestration. +- `type:ui` owns reusable visual components. +- `type:data-access` owns injectable state, API access, persistence, and + orchestration. +- `type:util` is the destination for new pure helpers and contracts only. + +For example, provider-neutral collection services that coordinate favorites, +recents, EPG, or playback persistence belong in +`libs/portal/shared/data-access`. Pure collection types and transformations stay +in `libs/portal/shared/util`, while reusable collection views stay in +`libs/portal/shared/ui`. Existing injectable or stateful services in a `util` +path are legacy debt, not precedent for new placement. ## Project Tags -Every Nx project should carry three tag families in `project.json`: +Every Nx project keeps one tag from each family in `project.json`: -1. `scope:*` - ownership area, for example `scope:portal`, `scope:workspace`, - `scope:shared`, `scope:electron`, `scope:e2e`, or `scope:dev-tools`. -2. `domain:*` - product/runtime domain, for example `domain:xtream`, - `domain:stalker`, `domain:m3u`, `domain:playback`, `domain:web`, or - `domain:shared-runtime`. -3. `type:*` - architectural role, for example `type:app`, `type:e2e`, - `type:dev-app`, `type:feature`, `type:ui`, `type:data-access`, - `type:util`, `type:tool`, or `type:website`. +1. `scope:*` records ownership, such as `scope:portal`, `scope:workspace`, + `scope:shared`, `scope:electron`, `scope:e2e`, or `scope:tools`. +2. `domain:*` records the product/runtime domain. +3. `type:*` records the architectural role. -`eslint.config.mjs` uses these tags with `@nx/enforce-module-boundaries`. -When adding a project, choose tags before adding imports so dependency direction -is clear from the start. +`eslint.config.mjs` enforces these type directions: -## Import Aliases +| Source tag | Allowed dependency type tags | +| ------------------ | ------------------------------ | +| `type:app` | feature, UI, data-access, util | +| `type:e2e` | feature, UI, data-access, util | +| `type:dev-app` | feature, UI, data-access, util | +| `type:website` | UI, util | +| `type:feature` | feature, UI, data-access, util | +| `type:ui` | UI, data-access, util | +| `type:data-access` | data-access, util | +| `type:util` | util | -Use scoped `@iptvnator/*` aliases from `tsconfig.base.json`. +Domain constraints in the same rule are additive to type constraints. If an +import violates either family, move the contract or implementation to its +proper owner instead of weakening a constraint. -Examples: +`workspace-shell-util` is a deliberate path/tag exception: +`libs/workspace/shell/util` is tagged `type:data-access` because it exports +injectable services that depend on `@iptvnator/services`. The web app imports +those services eagerly from `apps/web/src/app/app.routes.ts` without pulling +the lazy workspace shell feature into the initial bundle. -```ts -import { SettingsStore } from '@iptvnator/services'; -import { Playlist } from '@iptvnator/shared/interfaces'; -import { DialogService } from '@iptvnator/ui/components'; +## Import Aliases and Public APIs + +Use scoped aliases from `tsconfig.base.json` and expose public imports through a +library's `src/index.ts`. Do not introduce legacy bare aliases such as +`services`, `components`, `shared-interfaces`, or `database`, and avoid deep +imports unless a sub-entrypoint is explicitly configured. + +For a buildable library that has a local `package.json`, its `name` must match +the scoped alias. Nx uses that package name when rewriting buildable dependency +paths to `dist/` during `@nx/js:tsc` builds. + +## TypeScript File Size + +`tools/eslint/max-lines-config.mjs` is the single source of truth: + +- production TypeScript should stay below 300 lines and has a hard maximum of + 400; +- tests, E2E specs, and E2E infrastructure have a maximum of 1200; +- blank lines and comments are not counted. + +Pre-existing violations live in +`tools/eslint/max-lines-baseline.mjs`. That baseline may only shrink. After +splitting a baselined file, run +`node tools/eslint/generate-max-lines-baseline.mjs`; never add a new file to the +baseline. A genuinely inseparable new file needs a justified file-wide +directive, which the generator deliberately skips. + +## Command-Based Lint Targets + +Quote recursive globs so POSIX and Windows hosts lint the same files: + +```bash +eslint "apps//**/*.ts" +find apps/ -name '*.ts' | wc -l ``` -Do not introduce legacy bare aliases such as: - -- `components` -- `m3u-state` -- `m3u-utils` -- `services` -- `shared-interfaces` -- `shared-portals` -- `remote-control` -- `database` -- `database-schema` -- `database-path-utils` -- `workspace-dashboard-feature` -- `workspace-dashboard-data-access` - -The lint config blocks these aliases so new code uses the same visible -namespace and ownership convention. - -Buildable libraries that have a local `package.json` should use the same public -name as their scoped alias. Nx uses `package.json.name` when it rewrites -buildable dependency paths to `dist/` during `@nx/js:tsc` builds. - -## Dependency Direction - -- `type:feature` may use `type:feature`, `type:ui`, `type:data-access`, and - `type:util`. -- `type:ui` may use `type:ui`, `type:data-access`, and `type:util`. -- `type:data-access` may use `type:data-access` and `type:util`. -- `type:util` may use only `type:util`. - -If a change needs a dependency in the opposite direction, move the shared -contract into a lower-level library instead of weakening boundaries. - -Portal collection orchestration that reads/writes favorites, recent items, live -playback, or EPG data belongs in `libs/portal/shared/data-access`, not -`libs/portal/shared/util`. That keeps pure collection helpers importable by -Xtream/Stalker data-access libraries while allowing shared UI to use -provider-specific collection services without creating cycles. - -Note: `workspace-shell-util` (`libs/workspace/shell/util`) is tagged -`type:data-access` despite its path. It exports injectable services such as -`WorkspaceStartupPreferencesService` that depend on `@iptvnator/services`, and -it must stay eagerly importable from `apps/web/src/app/app.routes.ts` without -pulling the lazy-loaded workspace shell feature bundle into the initial chunk. +An unquoted `**` can expand to a shallow subset on POSIX while still returning +success. After editing such a target, compare ESLint's linted-file count with +the `find` count. ## CI Enforcement -The `Lint` job in `.github/workflows/ci.yml` runs -`pnpm nx affected --target=lint` on PRs and -`pnpm nx run-many --target=lint --all` on master pushes, so -`@nx/enforce-module-boundaries` violations, legacy bare-alias imports, and -`max-lines` violations fail CI. Root config or lockfile changes mark every -project affected, so the boundary rules cannot be dodged on PRs. Run -`pnpm run lint` locally before pushing. +The CI lint job runs affected projects on pull requests and all projects on +master pushes. Root config or lockfile changes affect every project, so module +boundaries, legacy-alias restrictions, and max-lines enforcement apply across +the workspace. diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 9cd127449..13357bc3a 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -10,7 +10,9 @@ Related: ## Summary -- Heavy non-EPG SQLite work no longer runs on Electron's main thread. +- Heavy non-EPG SQLite work no longer runs on Electron's main thread. The + explicitly lightweight download and EPG-specific SQLite handlers remain in + main. - A dedicated long-lived database worker now handles the slow Xtream and playlist database operations that were freezing the UI. - Renderer APIs stay stable. The main change is that progress and long-running @@ -28,6 +30,14 @@ The worker cutover addresses three concrete problems: ## Current Ownership +### Renderer and preload boundary + +These files own the renderer-facing database service and stable Electron +bridge: + +1. `libs/services/src/lib/database-electron.service.ts` +2. `apps/electron-backend/src/app/api/main.preload.ts` + ### Main-process runtime wiring These files own worker lifecycle and IPC bridging: @@ -48,7 +58,7 @@ These files own the worker protocol and the SQLite work itself: 3. `apps/electron-backend/src/app/workers/database.worker-connection.ts` 4. `apps/electron-backend/src/app/workers/worker-runtime-paths.ts` -### Pure database operation modules +### Database operation and support modules Keep SQL-heavy logic here so the worker entry remains a thin dispatcher: @@ -63,25 +73,53 @@ Keep SQL-heavy logic here so the worker entry remains a thin dispatcher: 9. `apps/electron-backend/src/app/database/operations/title-match.operations.ts` 10. `apps/electron-backend/src/app/database/operations/tmdb.operations.ts` 11. `apps/electron-backend/src/app/database/operations/epg-mapping.operations.ts` +12. `apps/electron-backend/src/app/database/operations/title-sources.operations.ts` +13. `apps/electron-backend/src/app/database/operations/vod-source-pin.operations.ts` -(plus the shared cancellation helper `operation-control.ts` in the same directory) +Focused helpers in the same directory keep the operation modules and dispatcher +small: + +1. `content-search.util.ts` +2. `title-token-glob.ts` +3. `operation-control.ts` +4. `performance-phase-capture.ts` +5. `xtream-content-operation-steps.ts` + +Worker operations use the shared table contract in +`libs/shared/database/src/lib/schema.ts`, imported by worker code through +`@iptvnator/shared/database/schema`. ## Worker Architecture ### Request flow -1. Renderer calls the existing preload API such as `window.electron.dbSaveContent`. -2. `ipcMain.handle(...)` in the Electron backend builds a payload and delegates - to `DatabaseWorkerClient`. -3. `DatabaseWorkerClient` lazily starts one long-lived `worker_threads` worker +1. Renderer `DatabaseService` calls the stable preload API, such as + `window.electron.dbSaveContent`. +2. The preload invokes an IPC channel owned by the focused event modules under + `apps/electron-backend/src/app/events/database/`. +3. `ipcMain.handle(...)` builds a payload and delegates to + `DatabaseWorkerClient`. +4. `DatabaseWorkerClient` lazily starts one long-lived `worker_threads` worker and correlates requests with a generated `requestId`. -4. The worker executes SQLite work and sends back either: +5. The protocol reaches the worker dispatcher, which obtains the worker + connection and delegates SQL work to an operation module using the shared + schema. +6. The worker sends back either: 1. `ready` 2. `event` 3. `response` -5. The main process resolves the IPC request and forwards worker events back to +7. The main process resolves the IPC request and forwards worker events back to the originating renderer process. +`requestId` and `operationId` have different scopes: + +1. `DatabaseWorkerClient` generates a fresh `requestId` for every request. It + correlates worker `event` and `response` messages with the pending main-side + transport request and is not renderer-visible operation state. +2. A renderer supplies an `operationId` for tracked long-running work. Progress + events and cooperative cancellation use that stable identity across the + renderer, preload, main process, and worker. + ### Why one long-lived worker - It avoids worker startup cost on every search/delete/import. @@ -309,6 +347,11 @@ Current shipped operation names: 4. `delete-playlist` 5. `delete-all-playlists` +All five are tracked. Save content, delete Xtream content, restore Xtream user +data, and delete playlist are cancellable. Delete all playlists deliberately +uses `cancellable: false`: its renderer-visible progress is tracked, but a +cancel request does not interrupt it. + The event is forwarded to the renderer as `DB_OPERATION_EVENT`. ### Cancellation contract @@ -329,7 +372,10 @@ If a worker operation is canceled: 3. the UI clears its busy state without treating the operation as success Cancellation is cooperative and chunk-based. Already committed SQLite batches -stay committed. +stay committed. For an operation's busy lifecycle, only the terminal +`completed`, `error`, or `cancelled` event settles UI state. The UI may set a +separate cancel-requested flag immediately so the cancel action cannot be +clicked twice while it waits for the authoritative terminal event. With the exact `IPTVNATOR_PERF_WORKER_PROFILING=1` opt-in, receipt of a cancel for a correlated active request also emits a @@ -370,8 +416,27 @@ falls back to the legacy progress API if the newer event channel is missing. ## Migrated Operations -The worker now owns all heavy non-EPG SQLite paths plus the remaining portal -state handlers that still used direct main-thread SQLite access. +The worker owns heavy non-EPG SQLite paths and portal state operations that +would otherwise block Electron main. + +### Deliberate direct-main exceptions + +Lightweight work that coordinates main-process runtime or remains +EPG-specific is not migrated incidentally: + +1. `apps/electron-backend/src/app/events/database/downloads.events.ts` keeps + small download-row reads/writes beside native dialogs, filesystem cleanup, + and the main-process download runtime. +2. `apps/electron-backend/src/app/events/database/epg-db.events.ts` keeps the + EPG programme-search IPC handler. +3. `apps/electron-backend/src/app/events/epg-fetch.service.ts`, + `apps/electron-backend/src/app/events/epg-mapping.service.ts`, and + `apps/electron-backend/src/app/events/epg-query.service.ts` keep EPG + freshness, mapping, and lookup behavior in their focused main-process + owners, while EPG parsing/import remains in its dedicated worker. + +Do not move these paths as part of unrelated database work. Reassess the +boundary if a handler becomes heavy enough to block the main process. ### Categories @@ -613,8 +678,8 @@ Current implementation paths: 2. `libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts` 3. `libs/portal/xtream/data-access/src/lib/with-recent-items.ts` 4. `libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts` -5. `libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts` -6. `libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts` +5. `libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts` +6. `libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts` ### Busy states @@ -635,10 +700,14 @@ renderer can actually paint the loading state instead of freezing. ### Worker bundling -`apps/electron-backend/build-worker.js` now bundles both: +`apps/electron-backend/build-worker.js` produces three bundles: -1. `epg-parser.worker.ts` -2. `database.worker.ts` +1. `apps/electron-backend/src/app/workers/epg-parser.worker.ts` → + `dist/apps/electron-backend/workers/epg-parser.worker.js` +2. `apps/electron-backend/src/app/workers/database.worker.ts` → + `dist/apps/electron-backend/workers/database.worker.js` +3. `apps/electron-backend/src/app/workers/playlist-refresh.worker.ts` → + `dist/apps/electron-backend/workers/playlist-refresh.worker.js` The worker build also aliases: @@ -666,12 +735,16 @@ artifacts for: 2. macOS app bundles 3. Windows unpacked app resources -The script checks: +The verifier's `workerFiles` list currently checks: 1. `epg-parser.worker.js` 2. `database.worker.js` -3. `better-sqlite3` in one approved unpacked node_modules location -4. Snap packaging compatibility settings for `better-sqlite3`: + +The playlist refresh bundle is produced by the worker build, but is not yet a +third explicit `workerFiles` check. The verifier also checks: + +1. `better-sqlite3` in one approved unpacked node_modules location +2. Snap packaging compatibility settings for `better-sqlite3`: - `snap.base = core22` - Snap launch args keep the X11 fallback - Snap and the other non-Flatpak Linux artifacts build on Ubuntu 22.04, while Flatpak builds on a separate Ubuntu 24.04 CI runner @@ -853,11 +926,15 @@ Available trace flags: Logs `window.electron.*` method calls crossing the preload bridge so you can see whether the renderer is still reaching Electron main. 3. `IPTVNATOR_TRACE_DB=1` - Logs `DatabaseWorkerClient` request dispatch, completion timing, and emitted - `DB_OPERATION_EVENT` payloads. + Logs redacted `DatabaseWorkerClient` request dispatch, completion timing, + and emitted `DB_OPERATION_EVENT` summaries. It also enables the safe SQL + statement-type summaries described below. 4. `IPTVNATOR_TRACE_SQL=1` - Logs SQLite statements for the shared main-process connection and the DB - worker connection using `better-sqlite3` verbose hooks. + Logs only a fixed allowlisted statement type such as `SELECT`, `INSERT`, or + `UPDATE` for the shared main-process and worker connections. The verbose + hook output passes through + `libs/shared/logging/src/lib/sql-trace-summary.ts`; expanded SQL text and + bound values are never emitted. 5. `IPTVNATOR_TRACE_WINDOW=1` Logs BrowserWindow loading, navigation, `unresponsive`, and `render-process-gone` transitions. @@ -882,19 +959,14 @@ CI=1 NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false pnpm nx run electron-backend:build --s ## Current Limitations -These are intentionally still out of scope for this first cut: +These remain intentionally out of scope: 1. moving network-heavy Xtream fetches off the current path -2. migrating every remaining small SQLite IPC handler to the worker +2. migrating explicitly lightweight download and EPG-specific main-process + handlers without evidence that they block Electron main 3. richer delete progress reporting for bulk destructive operations 4. repo-wide Angular/Jest cleanup for the currently failing web test baseline -(Request cancellation, originally listed here, has since shipped — see the -"Cancellation contract" section above: `DB_CANCEL_OPERATION` in -`apps/electron-backend/src/app/api/main.preload.ts`, `AbortError` production in -`database.worker.ts`, and `DatabaseService.cancelOperation` in -`libs/services/src/lib/database-electron.service.ts`.) - ## Extending The Worker When adding another heavy SQLite operation: diff --git a/docs/architecture/stalker-epg.md b/docs/architecture/stalker-epg.md index 437f251be..95833c2e5 100644 --- a/docs/architecture/stalker-epg.md +++ b/docs/architecture/stalker-epg.md @@ -109,10 +109,10 @@ GET load.php?type=itv&action=get_short_epg&ch_id={channel_id}&size={n}&JsHttpReq **Notes** - The response is normalized into shared `EpgItem[]` -- The list-preview path uses this directly -- The active-panel fallback maps the result into controlled `EpgProgram[]` +- Only the active-panel fallback uses this path and maps the result into + controlled `EpgProgram[]` -### `get_epg_info` (bulk active-panel source) +### `get_epg_info` (bulk row-preview and active-panel source) **Request** @@ -218,7 +218,8 @@ playlists. 1. User activates a live channel 2. The component ensures playback link resolution as before -3. The component calls `ensureBulkItvEpg(168)` on first use for the playlist +3. The component ensures `ensureBulkItvEpg(168)` has run; the eager row effect + normally started the same de-duplicated request before playback 4. `selectedItvEpgPrograms()` feeds `app-epg-timeline` 5. If the selected channel has no bulk programs, the component falls back to `get_short_epg` @@ -231,10 +232,11 @@ stream URL has been resolved; external playback keeps the full EPG-only panel. ### Channel row preview flow -Before the first live-channel playback, channel rows do not fetch EPG at all. - -After bulk EPG has been loaded once for the playlist, visible row previews are -derived locally from `bulkItvEpgByChannel`: +Once non-radio ITV channels render, the post-reset component effect calls +`ensureBulkItvEpg(168)`. It starts eagerly before playback and is de-duplicated +against the active-channel path. Individual rows never issue per-row requests. +As soon as the bulk request completes, visible row previews derive locally +from `bulkItvEpgByChannel`: - pick the current program for the channel, if one exists - compute progress from the cached program timestamps diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 930b2c554..5b5351365 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -267,6 +267,14 @@ list: Stalker has multiple real-world data shapes. The current implementation supports all three: +Within Stalker portal data access and feature code, +`isStalkerSeriesFlag()` is the canonical predicate for `is_series`. +`normalizeStalkerSeriesFlag()` delegates to it and produces the normalized +positive marker `true` or `undefined`. The activity normalizer in +`libs/shared/interfaces` keeps its dependency-neutral equivalent for dashboard +records. Both accept the same closed set: boolean `true`, numeric `1`, or string +`'1'`. Unsupported values do not by themselves classify a VOD item as a series. + 1. Regular Series (`/series`): - Seasons come from API resource (`serialSeasonsResource`). @@ -300,7 +308,12 @@ Stalker has multiple real-world data shapes. The current implementation supports ordering cannot start the wrong episode. - For unloaded VOD-series seasons, the CTA target label is derived from season metadata and rendered as `SxxE01` until episode details are loaded. -- Uses unique generated tracking IDs for episode playback position compatibility. +- Lazy VOD-series episodes use scoped tracking IDs derived from the parent + series ID, provider episode ID, season key, and episode number. The season + key follows the mapping fallback (`season_number`, then name, then ID). +- The previous season/episode hash remains available only as a compatibility + alias in `legacyTrackingId`. The scoped ID is the in-memory episode key, and + new playback positions always use it. - Quick-start actions preserve both their translation key and interpolation parameters when adapted for the Stalker CTA. Dropping `labelParams` exposes the raw `{{episode}}` placeholder. @@ -323,6 +336,22 @@ Series inline playback behavior is shared across all three modes: quick-start labels share the same naturally ordered season fallback so later seasons are not persisted as season 1. +### Playback Position Identity and Compatibility + +Legacy playback positions are reconciled lazily when the current parent +series' positions and mapped episodes are available: + +- The lookup is scoped to the current parent series. A legacy row is eligible + only when its stored season and episode metadata, when present, match the + mapped episode. +- An exact scoped tracking-ID row always wins. The old tracking ID may supply + an in-memory compatibility alias only when no exact row exists. +- On the next position write, IPTVnator persists the scoped row through the + strict, failure-propagating persistence boundary before removing a confirmed + legacy row. If the scoped write fails, the legacy row remains intact. +- This is an on-read/on-write compatibility path, not a database schema + migration or a bulk rewrite of saved positions. + The VOD-series contract is cross-surface: - Favorites and recently viewed records preserve the raw `is_series` flag and @@ -448,8 +477,9 @@ Stalker ITV now splits EPG usage: - active channel panel: bulk `get_epg_info` cached once per playlist and rendered through the shared EPG panel (`app-epg-timeline`, or `app-epg-list-view` in list mode) -- channel row preview: no pre-playback network requests; previews are derived - from cached bulk EPG only after the first active-channel fetch succeeds +- channel row preview: once ITV channels render, a post-reset effect eagerly + starts the de-duplicated bulk `get_epg_info` load; rows issue no per-row + request and derive previews from that cache - active panel fallback: `get_short_epg` when bulk EPG is missing or unsupported Full details are documented in [Stalker Portal EPG Architecture](./stalker-epg.md). @@ -467,10 +497,13 @@ This reduces duplicate UI logic across portal types and keeps compatibility beha ## Regression Coverage -Focused regression tests for Stalker VOD mode branching and the cross-surface -series contract live in: +The compatibility helper and focused regression coverage for Stalker VOD mode +branching and the cross-surface series contract live in: - `libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts` +- `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.ts` +- `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.spec.ts` +- `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts` - `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts` - `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts` diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 74f57c1e4..d0eb29d88 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -3,6 +3,40 @@ This document captures the Xtream Codes compatibility rules shared by the Electron and PWA paths. +## Runtime Selection And Ownership + +`provideXtreamDataSource()` selects `ElectronXtreamDataSource` only when +`RuntimeCapabilitiesService.supportsXtreamSqliteDataSource` proves that the +complete SQLite-backed Xtream bridge is available. Otherwise it selects +`PwaXtreamDataSource`. A generic Electron or `window.electron` check is not the +data-source capability contract. Older favorites/recent branches that still +probe `window.electron` directly are migration debt, not an alternate runtime +selection rule; changes in those paths should follow the selected data source +and explicit capabilities. + +Ownership follows the workspace boundaries: + +- routed screens and screen-session orchestration: + `libs/portal/xtream/feature` +- Xtream API, cache, Signal Store, and data sources: + `libs/portal/xtream/data-access` +- provider-neutral collection services and reusable multi-source + discovery/resolution: `libs/portal/shared/data-access` +- reusable presentation: `libs/portal/shared/ui` +- pure provider-neutral contracts/helpers: `libs/portal/shared/util` + +Persisted Xtream identity is playlist-scoped and content-type-aware: +`playlist_id + content.type + xtream_id`. Mixed collection keys likewise +include type plus provider ID because live, movie, and series IDs can collide. +Do not confuse the normalized SQLite row ID with provider `xtream_id`, +`stream_id`, or `series_id`, especially when recovering a hidden provider +category for detail playback. + +See [Nx Workspace Boundaries](./nx-workspace-boundaries.md), +[SQLite DB Worker](./sqlite-db-worker.md), +[Portal Detail Navigation](./portal-detail-navigation.md), and +[VOD Multi-Source](./vod-multi-source.md). + ## Connection Input Xtream server URLs are normalized through diff --git a/docs/superpowers/plans/2026-07-29-repository-skills-implementation-sync.md b/docs/superpowers/plans/2026-07-29-repository-skills-implementation-sync.md new file mode 100644 index 000000000..972d1ad45 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-repository-skills-implementation-sync.md @@ -0,0 +1,2013 @@ +# Repository Skills and Implementation Synchronization Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Synchronize IPTVnator's eight repository skills with current implementation, filter internal notes from public releases, and fix Stalker series flag and playback-position identity defects without losing legacy progress. + +**Architecture:** Keep the three workstreams independently testable: release output and guidance, provider-local Stalker compatibility, and repository skill/document maintenance. Release filtering is an additive public-extraction mode; Stalker compatibility uses series-scoped IDs plus an explicit legacy alias; mechanical skill constraints become a small dependency-free validator. + +**Tech Stack:** Node.js `node:test`, GitHub Actions YAML, Angular 21 signals and standalone services, TypeScript, Jest, Nx, Markdown skills and architecture documentation. + +--- + +## Execution Order and Commit Boundaries + +1. Bootstrap the locked workspace. +2. Add public release extraction. +3. Wire the release workflow and guidance. +4. Normalize Stalker series flags. +5. Add series-scoped Stalker episode IDs. +6. Reconcile and migrate legacy Stalker positions. +7. Document the Stalker behavior and add its release note. +8. Add mechanical repository-skill validation. +9. Refresh Nx and SQLite skills/docs. +10. Refresh theme, UI, Xtream, and Stalker skills/docs. +11. Run skill application scenarios and the complete validation ladder. + +Execute Tasks 1-11 strictly in numbered order in the shared worktree. Tasks 5 +and 6 must use the same worker because Task 6 consumes Task 5's deliberately +uncommitted interface changes. Do not parallelize edits, tests, staging, or +commits: the workers share one filesystem, Git index, and `HEAD`, and Task 5's +temporary signature change can also invalidate project-wide checks. Read-only +audits and reviews may run in parallel, but each task's implementation and +commit must finish before the next begins. + +### Task 1: Bootstrap and establish the baseline + +**Files:** +- Verify: `package.json` +- Verify: `pnpm-lock.yaml` +- Verify: `docs/superpowers/specs/2026-07-29-repository-skills-implementation-sync-design.md` + +- [ ] **Step 1: Confirm the intended branch and clean starting point** + +Run: + +```bash +git branch --show-current +git status --short +``` + +Expected: branch is `agent/skill-implementation-sync`; status is clean. + +- [ ] **Step 2: Install the locked dependencies** + +Run: + +```bash +pnpm install --frozen-lockfile +``` + +Expected: exit 0 and no change to `pnpm-lock.yaml`. + +- [ ] **Step 3: Verify Nx workspace discovery** + +Run: + +```bash +pnpm nx show projects +``` + +Expected: output includes `release-tools`, `portal-stalker-data-access`, +`portal-stalker-feature`, `shared-interfaces`, `web`, and `web-e2e`. + +- [ ] **Step 4: Record fresh behavior baselines** + +Run: + +```bash +node --test --test-reporter=tap tools/release/release-notes.test.mjs +pnpm exec jest \ + --config libs/portal/stalker/data-access/jest.config.ts \ + --runInBand \ + libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts +pnpm exec jest \ + --config libs/portal/stalker/feature/jest.config.ts \ + --runInBand \ + libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts +``` + +Expected: the existing tests pass before new red tests are introduced. + +### Task 2: Filter internal notes from the public release body + +**Files:** +- Modify: `tools/release/release-notes.test.mjs:21` +- Modify: `tools/release/release-notes.test.mjs:446` +- Modify: `tools/release/extract-changelog-section.mjs:27` + +- [ ] **Step 1: Hoist the shared fixture and add seven failing contracts** + +Move the existing `const changelog = [...]` fixture from inside +`describe('extractSection')` to module scope immediately before that suite, so +the existing and new suites share it. Add a module-level +`internalOnlyChangelog` fixture with the current generated internal block. +Import `extractPublicSection`, `parseExtractArguments`, and `runExtractorCli` +beside `extractSection`, then add four public-extraction tests: + +```js +describe('extractPublicSection', () => { + it('removes the generated internal block from a mixed release', () => { + assert.equal( + extractPublicSection(changelog, '0.24.0'), + ['### Features', '', '- **playback** — Up Next rail.'].join('\n') + ); + }); + + it('leaves a public-only release unchanged', () => { + const publicOnly = + '# 0.24.0 (2026-08-01)\n\n### Fixes\n\n- public fix'; + + assert.equal( + extractPublicSection(publicOnly, '0.24.0'), + '### Fixes\n\n- public fix' + ); + }); + + it('returns an empty body for an internal-only release', () => { + assert.equal( + extractPublicSection(internalOnlyChangelog, '0.24.0'), + '' + ); + }); + + it('preserves unrelated details blocks', () => { + const source = [ + '# 0.24.0 (2026-08-01)', + '', + '
', + 'Migration guide', + '', + 'Keep this text.', + '', + '
', + '', + '
', + 'Internal changes', + '', + '- **deps** — parser bump.', + '', + '
', + ].join('\n'); + const result = extractPublicSection(source, '0.24.0'); + + assert.match(result, /Migration guide<\/summary>/); + assert.match(result, /Keep this text\./); + assert.doesNotMatch(result, /Internal changes|parser bump/); + }); +}); +``` + +Add direct CLI-contract coverage without spawning a process or reading the +repository's real changelog: + +```js +describe('extract changelog CLI contract', () => { + it('parses --public before the version', () => { + assert.deepEqual( + parseExtractArguments(['--public', '0.24.0']), + { version: '0.24.0', publicOnly: true } + ); + }); + + it('rejects malformed, unknown, duplicate, or extra arguments', () => { + for (const args of [ + [], + ['0.24'], + ['--unknown', '0.24.0'], + ['--public', '--public', '0.24.0'], + ['0.24.0', 'extra'], + ]) { + assert.equal(parseExtractArguments(args), null); + const result = runExtractorCli(changelog, args); + assert.equal(result.exitCode, 2); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /Usage:/); + } + }); + + it('exits successfully with no output for an internal-only public body', () => { + assert.deepEqual( + runExtractorCli( + internalOnlyChangelog, + ['--public', '0.24.0'] + ), + { exitCode: 0, stdout: '', stderr: '' } + ); + }); +}); +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```bash +node --test tools/release/release-notes.test.mjs +``` + +Expected: FAIL because the module does not export the three new functions. + +- [ ] **Step 3: Implement exact-block public extraction** + +Add to `extract-changelog-section.mjs`: + +```js +const INTERNAL_DETAILS_BLOCK = + /(?:^|\n\n)
\nInternal changes<\/summary>\n\n[\s\S]*?\n\n<\/details>(?=\n\n|$)/g; + +/** + * Extracts the authored public body while preserving the complete changelog. + * + * @param {string} changelog full CHANGELOG.md content + * @param {string} version bare semver + * @returns {string | null} + */ +export function extractPublicSection(changelog, version) { + const section = extractSection(changelog, version); + + return section === null + ? null + : section.replace(INTERNAL_DETAILS_BLOCK, '').trim(); +} +``` + +Add pure argument and CLI-result boundaries: + +```js +const CLI_USAGE = + 'Usage: extract-changelog-section.mjs [--public] '; + +export function parseExtractArguments(args) { + const publicFlagCount = args.filter( + (argument) => argument === '--public' + ).length; + const positional = args.filter( + (argument) => argument !== '--public' + ); + + if ( + publicFlagCount > 1 || + positional.length !== 1 || + !/^\d+\.\d+\.\d+$/.test(positional[0]) + ) { + return null; + } + + return { + version: positional[0], + publicOnly: publicFlagCount === 1, + }; +} +``` + +Export `runExtractorCli(changelog, args)` returning +`{ exitCode, stdout, stderr }`. It must: + +- return exit 2 plus `CLI_USAGE` for invalid arguments; +- select `extractPublicSection` only in public mode; +- preserve the existing detailed missing-section diagnostic and exit 1; +- preserve the raw-mode empty-section diagnostic and exit 1; and +- return exit 0 with empty stdout/stderr for an internal-only public section. + +Refactor `main()` into a thin adapter: read `CHANGELOG.md`, call +`runExtractorCli(changelog, process.argv.slice(2))`, write the returned stdout +and stderr to their matching streams, and assign `process.exitCode`. Keep the +existing import guard. + +- [ ] **Step 4: Run the test and verify GREEN** + +Run: + +```bash +node --test --test-reporter=tap tools/release/release-notes.test.mjs +``` + +Expected: 44 tests pass, 0 fail. + +- [ ] **Step 5: Commit the public extractor** + +```bash +git add \ + tools/release/extract-changelog-section.mjs \ + tools/release/release-notes.test.mjs +git commit -m "fix(release): filter internal notes from public body" +``` + +### Task 3: Wire public release extraction and synchronize release guidance + +**Files:** +- Modify: `tools/release/release-notes.test.mjs` +- Modify: `tools/release/project.json` +- Modify: `.github/workflows/build-and-make.yaml` +- Modify: `.changes/README.md` +- Modify: `.codex/skills/release-cut/SKILL.md` +- Modify: `.claude/skills/release-cut/SKILL.md` +- Modify: `.codex/skills/release-notes/SKILL.md` +- Modify: `.claude/skills/release-notes/SKILL.md` +- Modify: `AGENTS.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Add a failing workflow contract test** + +Add `readFileSync` to the existing `node:fs` import and add: + +```js +it('uses public extraction for authored tag-release text', () => { + const workflow = readFileSync( + new URL( + '../../.github/workflows/build-and-make.yaml', + import.meta.url + ), + 'utf8' + ); + + assert.match( + workflow, + /extract-changelog-section\.mjs --public "\$\{VERSION\}"/ + ); +}); +``` + +- [ ] **Step 2: Run the workflow contract test and verify RED** + +Run: + +```bash +node --test tools/release/release-notes.test.mjs +``` + +Expected: FAIL because `build-and-make.yaml` does not pass `--public`. + +- [ ] **Step 3: Make the workflow request public output** + +Change the tag command to: + +```bash +BODY="$(node tools/release/extract-changelog-section.mjs --public "${VERSION}")" +``` + +Update the nearby workflow comment to state that the complete changelog keeps +internal notes and an internal-only release intentionally has no authored +public text. Add this input to `release-tools:test`: + +```json +"{workspaceRoot}/.github/workflows/build-and-make.yaml" +``` + +- [ ] **Step 4: Run the workflow contract test and verify GREEN** + +Run: + +```bash +node --test --test-reporter=tap tools/release/release-notes.test.mjs +``` + +Expected: 45 tests pass, 0 fail. + +- [ ] **Step 5: Replace the release-notes skill and its mirror** + +Apply this exact content to both release-notes skill paths: + +````markdown +--- +name: release-notes +description: "Use when a change may need a .changes note, the Release note gate fails, or deciding whether type: internal or no-release-note applies." +--- + +# Release Notes + +Every user-visible change gets one direct `.changes/-.md` file. The +area matches the conventional-commit scope; the body is present tense, user +language, one to three sentences, and at most 400 characters. + +## Format + +```markdown +--- +type: fix +area: stalker +issues: [1234] +screenshot: optional-manifest-slug +--- + +Stalker series now resume the correct episode. +``` + +`type` is `breaking`, `feature`, `fix`, `perf`, or `internal`. Omit optional +fields instead of inventing values. Never add a version or PR number. + +`internal` records invisible maintenance. It stays collapsed in `CHANGELOG.md` +but is omitted from the blog and the authored public GitHub body. GitHub's +generated commit list may still mention the underlying commits. + +## Skip or Label + +The gate auto-exempts website, E2E and mock-server apps, `*.spec.{js,ts}`, +`*.e2e.{js,ts}`, snapshots, any `/testing/` path, and Markdown. Other test-only, +docs, CI, workflow, or pure-refactor PRs use `no-release-note` when the gate +would otherwise require a note. At least one newly added direct +`.changes/*.md` file satisfies the gate. + +## Verify + +```bash +pnpm run release:notes:validate +``` + +Full format: `.changes/README.md`. Gate policy: +`tools/release/check-release-note-gate.mjs`. + +The `.codex` and `.claude` copies of this skill must remain byte-identical. +```` + +- [ ] **Step 6: Replace the release-cut skill and its mirror** + +Apply this exact content to both release-cut skill paths: + +````markdown +--- +name: release-cut +description: Use when preparing, cutting, tagging, publishing, or verifying an IPTVnator release or its release assets. +--- + +# Release Cut + +The tag build takes authored public text from the new CHANGELOG section. Keep +the full changelog committed before tagging. + +## Preflight + +Work from clean, current `master` with the intended remote named explicitly. +Confirm `package.json` contains bare semver, the exact `v` tag does not +exist locally or remotely, CI is green, and all notes validate. + +```bash +pnpm run release:notes:validate +pnpm run i18n:check +``` + +## Generate + +1. Set `package.json.version`. +2. Run `pnpm run release:notes:changelog`. +3. Minor release: run `pnpm run release:notes:blog` and finish every editorial + field. Patch release: edit the existing `vX-Y` post; do not scaffold or + force-overwrite it. +4. Capture required manifest screenshots only against mock servers: + `pnpm nx run electron-backend:build-e2e`, then + `pnpm run release:screenshots`. +5. Consume notes only after reviewing all generated output: + `node tools/release/build-release-notes.mjs --consume`. + +The consume command is the destructive boundary: it deletes the direct note +files. Stage only release-owned files, including exact website post/assets and +`git add -A -- .changes`, then commit and create the exact tag. + +```bash +git commit -m "chore(release): v0.24.0" +git tag v0.24.0 +``` + +## Push and External Effects + +Push only the intended branch and tag; never use broad `git push --tags`. + +```bash +git push --atomic origin \ + HEAD:refs/heads/master \ + refs/tags/v0.24.0 +``` + +Master and `v*` pushes can publish Docker images. The tag build creates a draft +GitHub release. Verify authored text plus generated commits and all required +macOS, Windows, DEB, RPM, Pacman (`.pacman`/`.pkg.tar.*`), AppImage, Snap, +Flatpak, updater metadata, blockmaps, and +`linux-frame-copy-runtime-sources.tar.xz`. + +After verification, manually publish the GitHub release. That publication +automatically verifies its Snap assets and uploads them to `edge`. +Installed-Snap smoke and candidate/stable promotion remain manual. Keep the +blog draft during artifact verification; publish it in a follow-up commit and +verify the website deployment. + +## Failure Safety + +Missing CHANGELOG section: regenerate, commit, delete the bad tag locally and +remotely only after resolving its exact target, then retag. Never publish a +draft until the source archive and Snap contract pass. + +The `.codex` and `.claude` copies of this skill must remain byte-identical. +```` + +- [ ] **Step 7: Synchronize canonical release documentation** + +Replace the `internal` paragraph in `.changes/README.md` with: + +```markdown +`type: internal` records invisible maintenance. Internal notes stay collapsed in +`CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the +authored public GitHub body by +`extract-changelog-section.mjs --public`. GitHub's generated commit list remains +separate. An internal-only release can therefore have an empty authored body. +``` + +Replace its skip guidance with the exact gate behavior: + +```markdown +The gate auto-exempts website, E2E and mock-server apps, `*.spec.{js,ts}`, +`*.e2e.{js,ts}`, snapshots, any `/testing/` path, and Markdown. For other +test-only, documentation, CI/workflow, or pure-refactor changes under +`apps/`/`libs/`, apply `no-release-note` when no user-visible note is warranted. +``` + +Add the same public/internal distinction and automatic Snap `edge`/Docker side +effects to the mirrored release-policy sections in `AGENTS.md` and `CLAUDE.md`. +Keep their process bullets textually synchronized. + +- [ ] **Step 8: Verify release guidance and mirrors** + +Run: + +```bash +cmp -s \ + .codex/skills/release-cut/SKILL.md \ + .claude/skills/release-cut/SKILL.md +cmp -s \ + .codex/skills/release-notes/SKILL.md \ + .claude/skills/release-notes/SKILL.md +! rg -n '^[[:space:]]*git push .*--tags' \ + .codex/skills/release-cut/SKILL.md \ + .claude/skills/release-cut/SKILL.md +! rg -n 'separate manual flow' \ + .codex/skills/release-cut/SKILL.md \ + .claude/skills/release-cut/SKILL.md +node --test \ + tools/release/release-notes.test.mjs \ + tools/release/release-note-gate.test.mjs \ + tools/release/build-release-notes.test.mjs \ + tools/release/screenshot-guards.test.mjs +pnpm nx test release-tools +``` + +Expected: both `cmp` commands and both negated `rg` checks exit 0; no +executable broad tag push or stale manual-flow wording exists; all release +tests pass. + +- [ ] **Step 9: Commit workflow and release guidance** + +```bash +git add \ + .github/workflows/build-and-make.yaml \ + tools/release/project.json \ + tools/release/release-notes.test.mjs \ + .changes/README.md \ + .codex/skills/release-cut/SKILL.md \ + .codex/skills/release-notes/SKILL.md \ + .claude/skills/release-cut/SKILL.md \ + .claude/skills/release-notes/SKILL.md \ + AGENTS.md \ + CLAUDE.md +git commit -m "docs(release): synchronize release workflow guidance" +``` + +### Task 4: Normalize every Stalker series flag decision + +**Files:** +- Modify: `libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts` +- Modify: `libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts` +- Modify: `libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts` +- Verify: `libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts` + +- [ ] **Step 1: Add selection characterization and a failing progress regression** + +In `stalker-catalog-facade.service.spec.ts`, retain the store mock in a local +variable and add parameterized coverage: + +```ts +it.each([true, 1, '1'] as const)( + 'selects a VOD series when is_series is %p', + (isSeries) => { + const service = TestBed.inject(StalkerCatalogFacadeService); + const store = TestBed.inject(StalkerStore) as unknown as { + setSelectedItem: jest.Mock; + }; + + service.selectItem({ + id: '42', + name: 'Boolean Series', + is_series: isSeries, + }); + + expect(store.setSelectedItem).toHaveBeenCalledWith( + expect.objectContaining({ id: '42', is_series: true }) + ); + } +); + +it.each([true, 1, '1'] as const)( + 'reports series progress semantics when is_series is %p', + (isSeries) => { + const service = TestBed.inject(StalkerCatalogFacadeService); + + expect( + service.getItemProgress({ + id: '42', + name: 'Series', + is_series: isSeries, + }) + ).toEqual({ hasSeriesProgress: false }); + } +); +``` + +Add `false` and `0` non-series cases that expect an undefined normalized +`is_series` selection field and ordinary VOD progress +`{ progress: 0, isWatched: false }`. + +Use the smallest valid `StalkerVodSource` fixture accepted by the compiler; do +not cast away missing required fields if the existing factory already provides +one. + +- [ ] **Step 2: Run the facade spec and verify RED** + +Run: + +```bash +pnpm exec jest \ + --config libs/portal/stalker/feature/jest.config.ts \ + --runInBand \ + libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts +``` + +Expected: the boolean progress case fails because the facade currently checks +only `1` and `'1'`. Selection cases already pass through normalization inside +`buildStalkerSelectedVodItem`; they are characterization coverage for removing +the redundant local comparison. + +- [ ] **Step 3: Route facade and detail decisions through the normalizer** + +Import `isStalkerSeriesFlag` from +`@iptvnator/portal/stalker/data-access`. Replace both direct comparisons in +`StalkerCatalogFacadeService`: + +```ts +const needsSeriesFetch = + this.contentType() === 'vod' && isStalkerSeriesFlag(item.is_series); +``` + +```ts +const isSeries = + this.contentType() === 'series' || + isStalkerSeriesFlag(item.is_series); +``` + +Use the same helper in `StalkerCatalogDetailComponent.isSeriesDetail`: + +```ts +return Boolean( + item && + (this.contentType() === 'series' || + isStalkerSeriesFlag(item.is_series)) +); +``` + +- [ ] **Step 4: Lock the store resource to the same three-value contract** + +Change the `vodSeriesSeasonsResource` guard in +`with-stalker-series.feature.ts` from truthiness to: + +```ts +!isStalkerSeriesFlag(selectedItem.is_series) +``` + +Import the helper from `../../stalker-vod.utils`. In +`with-stalker-series.feature.spec.ts`, parameterize the existing +`is_series: '1'` resource test over `true`, `1`, and `'1'`, and add one +unsupported truthy value such as `'true'` that must not issue the season +request. + +- [ ] **Step 5: Run targeted Stalker tests and verify GREEN** + +Run: + +```bash +pnpm exec jest \ + --config libs/portal/stalker/feature/jest.config.ts \ + --runInBand \ + libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts +pnpm exec jest \ + --config libs/portal/stalker/data-access/jest.config.ts \ + --runInBand \ + libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts +! rg -n \ + 'is_series\s*===|String\(.*is_series|!selectedItem\.is_series' \ + libs/portal/stalker +``` + +Expected: both Jest commands and the negated `rg` check pass; no raw +series-flag decisions remain. Logging and value-preserving serialization may +still mention `is_series`. + +- [ ] **Step 6: Commit the normalized flag contract** + +```bash +git add \ + libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts \ + libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts \ + libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts \ + libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts \ + libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts +git commit -m "fix(stalker): normalize catalog series flags" +``` + +### Task 5: Give lazy VOD-series episodes parent-scoped identities + +**Files:** +- Modify: `libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts` +- Modify: `libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` + +- [ ] **Step 1: Replace the old identity test with collision regressions** + +Extend the local episode metadata type: + +```ts +type EpisodeWithMetadata = { + custom_sid?: string; + id?: string; + legacyTrackingId?: number; + originalId?: string; + originalCmd?: string; +}; +``` + +Update every `mapVodSeriesEpisodes` call to pass an options object. Add tests +which prove: + +1. the same parent, provider episode, season, and episode number are + deterministic; +2. the same season/episode in parent series `100` and `200` produces different + `id` values; +3. those two episodes retain the same `legacyTrackingId`; +4. two provider episode IDs within one parent also produce different IDs; and +5. `mapRegularSeriesEpisodes` keeps its existing identity behavior. + +Representative calls: + +```ts +const firstSeries = mapVodSeriesEpisodes(seasons, { + parentSeriesId: 100, + fallbackPoster: 'poster.jpg', +}); +const secondSeries = mapVodSeriesEpisodes(seasons, { + parentSeriesId: 200, + fallbackPoster: 'poster.jpg', +}); +``` + +- [ ] **Step 2: Run the adapter spec and verify RED** + +Run: + +```bash +pnpm exec jest \ + --config libs/portal/stalker/data-access/jest.config.ts \ + --runInBand \ + libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts +``` + +Expected: FAIL because the current adapter has no options object or +`legacyTrackingId`, and identical season/episode pairs collide across parents. + +- [ ] **Step 3: Add an explicit mapping contract** + +In `stalker-series.adapters.ts`, add: + +```ts +export interface MapVodSeriesEpisodesOptions { + parentSeriesId: string | number; + fallbackPoster?: string; +} + +export interface StalkerMappedEpisode extends XtreamSerieEpisode { + legacyTrackingId?: number; + originalId?: string; + originalCmd?: string; +} +``` + +Replace the VOD branch of `generateEpisodeId` with two named helpers: + +```ts +function generateLegacyVodEpisodeId( + episodeNum: number, + seasonKey: string +): number { + return hashString(`vod_${seasonKey}_${episodeNum}`); +} + +function generateVodEpisodeId(options: { + parentSeriesId: string | number; + providerEpisodeId: string; + seasonKey: string; + episodeNum: number; +}): number { + return hashString( + JSON.stringify([ + 'vod', + String(options.parentSeriesId), + options.providerEpisodeId, + options.seasonKey, + options.episodeNum, + ]) + ); +} +``` + +Keep the regular-series `generateEpisodeId` path unchanged. Change the public +signature to: + +```ts +export function mapVodSeriesEpisodes( + seasons: ReadonlyArray, + options: MapVodSeriesEpisodesOptions +): Record +``` + +For every VOD episode, set: + +```ts +const providerEpisodeId = String(episode.id ?? ''); +const legacyTrackingId = generateLegacyVodEpisodeId( + episodeNum, + seasonKey +); +const trackingId = generateVodEpisodeId({ + parentSeriesId: options.parentSeriesId, + providerEpisodeId, + seasonKey, + episodeNum, +}); +``` + +Use `options.fallbackPoster` for the fallback artwork and expose both +`originalId: providerEpisodeId` and `legacyTrackingId` on the mapped episode. + +- [ ] **Step 4: Pass the selected parent identity at the only production call** + +In `StalkerSeriesViewComponent.mappedSeasons`, capture `displayItem` once and +call: + +```ts +mapVodSeriesEpisodes(this.vodSeriesSeasons(), { + parentSeriesId: this.toSeriesId(displayItem?.id ?? 0), + fallbackPoster: displayItem?.info?.movie_image, +}) +``` + +Do not derive the parent from a season or episode field; the selected catalog +item, normalized through the same `toSeriesId` path used by persistence, is the +scope represented by `seriesXtreamId`. + +- [ ] **Step 5: Run the adapter spec and verify GREEN** + +Run: + +```bash +pnpm exec jest \ + --config libs/portal/stalker/data-access/jest.config.ts \ + --runInBand \ + libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts +``` + +Expected: all scoped-identity and legacy-ID cases pass. + +Do not commit yet. Task 6 wires legacy reconciliation in the same atomic +behavior commit so a scoped-ID build can never ship without resume +compatibility. + +### Task 6: Reconcile and migrate legacy Stalker playback positions + +**Files:** +- Create: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.ts` +- Create: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.spec.ts` +- Create: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts` +- Verify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` +- Include uncommitted Task 5 changes in: + `libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts` +- Include uncommitted Task 5 changes in: + `libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts` + +- [ ] **Step 1: Add failing pure reconciliation tests** + +Create the compatibility spec and cover these cases: + +1. an exact new ID wins when both new and legacy rows exist, while the + compatible legacy row remains available only as cleanup metadata; +2. a legacy row from the current `seriesXtreamId` becomes an alias under the + new ID; +3. a row with a different parent series is ignored; +4. present `seasonNumber` or `episodeNumber` values must match the mapped + episode, while absent legacy metadata remains compatible; +5. episodes without `legacyTrackingId` never receive an alias; +6. saving a migrated position awaits the new save before clearing the old ID; +7. a rejected save never clears the old row; +8. save and clear helpers reject legacy cleanup for a different/missing + parent, equal old/new IDs, non-episode content, or conflicting playlist; and +9. clearing an exact scoped row clears its confirmed legacy ID before the + scoped ID, after which a fresh reconciliation cannot resurrect progress; +10. a rejected legacy clear leaves the exact scoped row untouched, while a + scoped-clear failure after legacy cleanup also leaves exact progress + available. + +Use fixtures with two parent series that share a legacy tracking ID. Assert +call order with an array pushed by the repository mocks rather than relying +only on invocation counts. + +- [ ] **Step 2: Run the new spec and verify RED** + +Run: + +```bash +pnpm exec jest \ + --config libs/portal/stalker/feature/jest.config.ts \ + --runInBand \ + libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.spec.ts +``` + +Expected: FAIL because the module does not exist. + +- [ ] **Step 3: Implement the pure compatibility boundary** + +Export: + +```ts +export interface ReconciledStalkerSeriesPositions { + positionsByTrackingId: Map; + legacyPositionByTrackingId: Map; +} + +export function reconcileStalkerSeriesPositions(options: { + seriesXtreamId: number; + episodesBySeason: Readonly< + Record + >; + seriesPositions: readonly PlaybackPositionData[]; +}): ReconciledStalkerSeriesPositions; + +export async function saveStalkerSeriesPosition(options: { + repository: Pick< + PortalPlaybackPositions, + 'savePlaybackPosition' | 'clearPlaybackPosition' + >; + playlistId: string; + position: PlaybackPositionData; + legacyPosition?: PlaybackPositionData; +}): Promise; + +export async function clearStalkerSeriesPosition(options: { + repository: Pick; + playlistId: string; + position: PlaybackPositionData; + legacyPosition?: PlaybackPositionData; +}): Promise; +``` + +`reconcileStalkerSeriesPositions` must: + +- index only `contentType: 'episode'` rows whose `seriesXtreamId` equals the + requested parent; +- receive those rows only from + `getSeriesPlaybackPositions(playlistId, seriesXtreamId)`; never broaden the + migration with `getAllPlaybackPositions`; +- preserve exact current-ID rows first; +- inspect `legacyTrackingId` only on `StalkerMappedEpisode`; +- require a present legacy `seasonNumber` and/or `episodeNumber` to equal the + mapped episode (`null`/`undefined` means absent; compare other runtime values + numerically); +- inspect a compatible legacy row even when an exact scoped row already won; +- record that original legacy row in `legacyPositionByTrackingId`, keyed by + the new tracking ID, so exact-row save/clear operations can remove it; and +- only when no exact row exists, clone the compatible legacy row under the new + ID, setting the current parent, season, and episode metadata without mutating + the source row. + +Factor one private ownership predicate shared by both persistence helpers. +`saveStalkerSeriesPosition` must always await the new save first. It clears the +legacy row and returns `true` only when all of these hold: + +```ts +position.contentType === 'episode' +legacyPosition?.contentType === 'episode' +position.contentXtreamId !== legacyPosition.contentXtreamId +position.seriesXtreamId != null +legacyPosition.seriesXtreamId != null +position.seriesXtreamId === legacyPosition.seriesXtreamId +(!position.playlistId || position.playlistId === playlistId) +(!legacyPosition.playlistId || legacyPosition.playlistId === playlistId) +``` + +Otherwise it returns `false` after saving the new row. Let save/clear failures +reject so callers cannot mistake a partial migration for success. + +When the same ownership predicate confirms a distinct legacy row, +`clearStalkerSeriesPosition` must clear the legacy ID first and the scoped +`position.contentXtreamId` second, then return `true`. This ordering is +load-bearing: if legacy cleanup fails, the exact row remains; if scoped cleanup +then fails, the exact row still represents the uncleared state and the legacy +row cannot resurrect. Without a confirmed legacy row, clear only the scoped ID +and return `false`. Tests should back repository rows with a `Map`, remove rows +in the mock, and rerun reconciliation after a dual clear. + +- [ ] **Step 4: Run the pure compatibility spec and verify GREEN** + +Run: + +```bash +pnpm exec jest \ + --config libs/portal/stalker/feature/jest.config.ts \ + --runInBand \ + libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.spec.ts +``` + +Expected: all reconciliation, guard, and save-before-clear tests pass. + +- [ ] **Step 5: Add failing component integration regressions in a focused spec** + +Create `stalker-series-view.position-compatibility.spec.ts` with the smallest +TestBed harness needed for position loading, lazy episodes, time updates, and +toggle requests. Do not append these cases to +`stalker-series-view.component.spec.ts`: it is already close to the 1200-line +test ceiling. Prove: + +- positions returned before lazy episodes load are reconciled after + `loadEpisodesForSeason` populates the season; +- an exact scoped position beats a compatible legacy row but retains that + legacy row as cleanup metadata; +- a slow response for series A cannot overwrite positions after selection + changes to series B; +- inline time updates and watched toggles save the scoped ID and then clear a + confirmed legacy ID; +- after an exact or migrated save, an effect/reconciliation rerun does not + restore the removed legacy alias from raw rows; +- clearing a scoped watched state also clears its confirmed legacy row, and a + reload/reconciliation does not make the old progress reappear; and +- no legacy clear occurs for regular series or an unconfirmed legacy alias. + +Use deferred promises for the stale-response case. Instantiate the real +component with its injected repository/store mocks so the signals and effects, +not only the pure helper, are exercised. + +- [ ] **Step 6: Run the component spec and verify RED** + +Run: + +```bash +pnpm exec jest \ + --config libs/portal/stalker/feature/jest.config.ts \ + --runInBand \ + libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts +``` + +Expected: the lazy reconciliation, stale-response, and migration assertions +fail against the current single map/direct-save implementation. + +- [ ] **Step 7: Store raw rows separately and reconcile whenever episodes change** + +In `StalkerSeriesViewComponent`, add: + +```ts +private readonly rawSeriesPositions = + signal([]); +private readonly legacyPositionByTrackingId = + signal>(new Map()); +private seriesPositionsLoadGeneration = 0; +``` + +Add one effect that reads `displayItem`, `mappedSeasons`, and +`rawSeriesPositions`, calls `reconcileStalkerSeriesPositions`, then updates +`episodePlaybackPositions` and `legacyPositionByTrackingId`. Clear all three +position stores when no playlist/series is selected. + +Refactor `loadSeriesPositions` to capture a monotonically increasing +generation plus the requested playlist/series IDs. After the await, publish +the raw rows only if: + +- the generation is still current; +- the current playlist ID still matches; and +- `toSeriesId(displayItem()?.id)` still matches. + +This makes a lazy episode load naturally re-run reconciliation and prevents +detail-to-detail races from publishing stale rows. + +- [ ] **Step 8: Route position writes through the migration helper** + +Keep policy and ownership checks in the new helper so the already-baselined +production component gains only signal/effect wiring and thin repository +coordination. + +Add a private method that calls `saveStalkerSeriesPosition` with the legacy row +looked up by the new `contentXtreamId`, whether the rendered value came from a +legacy fallback or an already-exact scoped row. On successful save: + +- filter both the scoped ID and the confirmed legacy + `contentXtreamId` from `rawSeriesPositions`, then append the saved scoped + row; +- remove the consumed legacy alias from + `legacyPositionByTrackingId`; and +- update the rendered map immediately. + +Use this method from: + +- throttled inline time updates; +- `handlePlaybackToggleRequested` when `nextPosition` is present; and +- matching runtime bridge updates. + +The runtime bridge path intentionally repeats the facade's idempotent upsert: +only the series view has the episode mapping needed to order “save scoped row, +then clear confirmed legacy row.” + +For a clear toggle, call `clearStalkerSeriesPosition`, then remove both +confirmed rows from raw/rendered state. Propagate either clear failure rather +than publishing a false success. Never infer a legacy ID from season/episode +numbers at write time; only the reconciliation result may authorize deletion. +The focused component spec must force reconciliation after a successful save +and refetch/reconcile after a successful dual clear to prove the legacy row +cannot reappear. + +- [ ] **Step 9: Run all affected Stalker unit tests** + +Run: + +```bash +pnpm nx test portal-stalker-data-access --runInBand +pnpm nx test portal-stalker-feature --runInBand +``` + +Expected: both projects pass, including all flag, ID, reconciliation, lazy-load, +and migration regressions. + +- [ ] **Step 10: Commit scoped identity and legacy migration atomically** + +```bash +git add \ + libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts \ + libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts \ + libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.spec.ts \ + libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.ts \ + libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts \ + libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +git commit -m "fix(stalker): preserve progress with scoped episode IDs" +``` + +### Task 7: Publish the Stalker compatibility contract + +**Files:** +- Create: `.changes/stalker-series-position-identity.md` +- Modify: `docs/architecture/stalker-portal.md` +- Modify: `CLAUDE.md` +- Modify: `libs/shared/interfaces/src/lib/stalker-item.normalizer.spec.ts` +- Modify: `libs/shared/interfaces/src/lib/stalker-item.normalizer.ts` + +- [ ] **Step 1: Add the required user-facing release note** + +Create: + +```markdown +--- +type: fix +area: stalker +--- + +Stalker VOD series now report progress correctly when portals return boolean series flags, keep episode progress separate between shows, and resume positions saved by earlier versions. +``` + +- [ ] **Step 2: Lock the shared normalizer contract** + +In `stalker-item.normalizer.spec.ts`, add parameterized assertions that +`extractStalkerItemType` classifies `is_series: true`, `1`, and `'1'` as +series. Update the normalizer's “truthy” doc comment to name those exact shapes. +This is contract coverage/documentation for the already-correct shared +normalizer; do not duplicate the feature fix in `shared-interfaces`. + +Run: + +```bash +pnpm nx test shared-interfaces --runInBand +``` + +Expected: PASS. + +- [ ] **Step 3: Document scoped episode identity and migration** + +In `docs/architecture/stalker-portal.md`, update the VOD-series and playback +position sections to state: + +- `is_series` is normalized only from `true`, `1`, or `'1'`; +- lazy VOD-series tracking IDs include parent series ID, provider episode ID, + season key, and episode number; +- the previous season/episode hash is exposed only as a compatibility alias; +- legacy rows are considered only inside the current parent-series query and + must satisfy any stored season/episode metadata; +- exact scoped rows win; +- the new row is saved before a confirmed legacy row is removed; and +- no database schema migration or bulk rewrite is performed. + +Add the compatibility helper, pure spec, and focused component integration spec +to the regression-coverage file inventory. + +- [ ] **Step 4: Keep the root architecture summary current** + +Update the Stalker series entry in `CLAUDE.md` with the same concise identity +and migration rule. Do not add this implementation detail to `AGENTS.md`; its +process guidance is unaffected. + +- [ ] **Step 5: Validate docs and the note** + +Run: + +```bash +pnpm run release:notes:validate +pnpm nx test shared-interfaces --runInBand +git diff --check +``` + +Expected: all commands pass. + +- [ ] **Step 6: Commit the Stalker docs and release note** + +```bash +git add \ + .changes/stalker-series-position-identity.md \ + docs/architecture/stalker-portal.md \ + CLAUDE.md \ + libs/shared/interfaces/src/lib/stalker-item.normalizer.spec.ts \ + libs/shared/interfaces/src/lib/stalker-item.normalizer.ts +git commit -m "docs(stalker): record series position compatibility" +``` + +### Task 8: Add mechanical validation for committed repository skills + +**Files:** +- Create: `tools/skills/validate-repository-skills.test.mjs` +- Create: `tools/skills/validate-repository-skills.mjs` +- Create: `tools/skills/project.json` +- Modify: `package.json` +- Modify: `AGENTS.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Write validator tests against temporary repositories** + +Export a `validateRepositorySkills({ rootDir })` function so the tests can use +isolated fixtures. Build one valid temporary repository per test with +`node:fs/promises` and `mkdtemp`, then assert diagnostics for: + +1. a valid trigger-only skill whose quoted description contains a YAML + colon-space value such as `type: internal`; +2. a frontmatter `name` that differs from its directory; +3. a description that does not start with `Use when`; +4. a description over 500 characters; +5. a skill over 500 words; +6. a backticked, literal repository path that does not exist; and +7. byte-different `.codex` / `.claude` release mirrors. + +The valid fixture must prove that wildcard/placeholder examples such as +`apps/*-e2e` and `libs//feature` are skipped by path validation. + +- [ ] **Step 2: Run the validator tests and verify RED** + +Run: + +```bash +node --test tools/skills/validate-repository-skills.test.mjs +``` + +Expected: FAIL because the validator module does not exist. + +- [ ] **Step 3: Implement the dependency-free validator** + +`validateRepositorySkills` must: + +- discover direct `.codex/skills/*/SKILL.md` files in sorted order; +- parse the opening `---` frontmatter without adding a YAML dependency, + unquoting matching single- or double-quoted one-line scalar values before + validation; +- require `name` to equal the containing directory; +- require a one-line `description` that begins with `Use when` and is at most + 500 characters; +- count Markdown words and reject a complete skill over 500 words; +- inspect backticked tokens beginning with `apps/`, `libs/`, `docs/`, `tools/`, + `.changes/`, or `.github/`, plus tokens exactly equal to `package.json`, + `pnpm-lock.yaml`, `nx.json`, `tsconfig.base.json`, `eslint.config.mjs`, + `CHANGELOG.md`, `AGENTS.md`, or `CLAUDE.md`; +- skip tokens containing glob or placeholder characters (`*`, `?`, `[`, `]`, + `{`, `}`, `<`, `>`) and require every remaining literal path to exist; +- compare the `release-notes` and `release-cut` `.codex` files byte-for-byte + with their `.claude` mirrors; and +- return all diagnostics in deterministic order instead of failing at the + first error. + +The CLI entrypoint prints one diagnostic per line to stderr and sets a nonzero +exit code. A valid run prints the number of checked skills. Guard CLI execution +with `fileURLToPath(import.meta.url)` so importing the module in `node:test` +never runs the repository-root validation as a side effect. + +- [ ] **Step 4: Add an Nx project and package entrypoint** + +Create `tools/skills/project.json`: + +```json +{ + "name": "repository-skills", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "tools/skills", + "tags": ["scope:tools", "domain:skills", "type:tool"], + "targets": { + "test": { + "executor": "nx:run-commands", + "cache": true, + "inputs": [ + "{workspaceRoot}/tools/skills/validate-repository-skills.mjs", + "{workspaceRoot}/tools/skills/validate-repository-skills.test.mjs" + ], + "options": { + "command": "node --test tools/skills/validate-repository-skills.test.mjs", + "cwd": "{workspaceRoot}" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "node --check tools/skills/validate-repository-skills.mjs", + "cwd": "{workspaceRoot}" + } + } + } +} +``` + +Add: + +```json +"skills:validate": "node tools/skills/validate-repository-skills.mjs" +``` + +to `package.json` scripts without reordering unrelated scripts. + +- [ ] **Step 5: Run the test suite and verify GREEN** + +Run: + +```bash +node --test tools/skills/validate-repository-skills.test.mjs +pnpm nx show project repository-skills +pnpm nx test repository-skills +``` + +Expected: the unit suite passes and Nx discovers the tagged tool project. +`pnpm run skills:validate` is intentionally deferred until Tasks 9-10 make the +current eight skills comply. + +- [ ] **Step 6: Document the maintenance hook and refresh the skill inventory** + +Merge these exact maintenance bullets into the existing Agent Bootstrap/process +section in both `AGENTS.md` and `CLAUDE.md`; keep the shared wording identical: + +```markdown +- Repository-specific skills live under `.codex/skills/`. +- Frontmatter descriptions are trigger-only and begin with `Use when`; keep + each skill at or below 500 words. +- Run `pnpm run skills:validate` after editing a committed skill or a literal + path it documents. +- Keep `.codex` and `.claude` copies of `release-notes` and `release-cut` + byte-identical. +``` + +Then replace the stale six-entry `AGENTS.md` `## Repo Skills` inventory with +paths only for all eight committed skills: + +```markdown +## Repo Skills + +- `.codex/skills/iptvnator-nx-architecture/SKILL.md` +- `.codex/skills/iptvnator-sqlite-db-worker/SKILL.md` +- `.codex/skills/iptvnator-theme-style/SKILL.md` +- `.codex/skills/iptvnator-ui-design/SKILL.md` +- `.codex/skills/release-cut/SKILL.md` +- `.codex/skills/release-notes/SKILL.md` +- `.codex/skills/stalker-portal/SKILL.md` +- `.codex/skills/xtream-electron/SKILL.md` + +Descriptions and trigger conditions are canonical in each skill's frontmatter; +do not duplicate them here. +``` + +- [ ] **Step 7: Commit the validator** + +```bash +git add \ + tools/skills/validate-repository-skills.test.mjs \ + tools/skills/validate-repository-skills.mjs \ + tools/skills/project.json \ + package.json \ + AGENTS.md \ + CLAUDE.md +git commit -m "test(skills): validate repository skill contracts" +``` + +### Task 9: Refresh the Nx and SQLite ownership skills + +**Files:** +- Modify: `.codex/skills/iptvnator-nx-architecture/SKILL.md` +- Modify: `.codex/skills/iptvnator-sqlite-db-worker/SKILL.md` +- Modify: `docs/architecture/nx-workspace-boundaries.md` +- Modify: `docs/architecture/sqlite-db-worker.md` + +- [ ] **Step 1: Replace both frontmatter descriptions with trigger-only text** + +Use exactly: + +```yaml +description: Use when deciding where IPTVnator code belongs, creating or moving Nx projects, changing scoped aliases or tags, editing lint targets, or validating module boundaries. +``` + +and: + +```yaml +description: Use when changing Electron SQLite IPC, database-worker operations, request-scoped progress or cancellation, worker packaging, or runtime verification of non-EPG database work. +``` + +Remove the duplicate “Use this skill when…” sentence from each body. + +- [ ] **Step 2: Rewrite the Nx skill around decisions, not a stale inventory** + +Keep it below 500 words and include these exact contracts: + +- bootstrap with `pnpm install --frozen-lockfile`, then discover reality with + `pnpm nx show projects`, `pnpm nx show project `, and the two + `--withTarget test|e2e` queries; +- current app groups: `web`, `electron-backend`, `web-backend`, + `remote-control-web`, `website`, both E2E apps, and both provider mock + servers; current tool groups: ESLint, packaging, release, and repository + skills; discovery remains authoritative when this snapshot changes; +- use `apps/` for runtime/dev/E2E/mock apps, `tools/` for repo automation, and + domain libraries under `libs/`; +- `type:feature` owns route and screen orchestration, `type:ui` reusable visual + components, `type:data-access` injectable state/API/persistence/orchestration, + and `type:util` pure code only; +- provider-neutral collection services that coordinate persistence belong in + `libs/portal/shared/data-access`; pure helpers stay in + `libs/portal/shared/util`; reusable views stay in + `libs/portal/shared/ui`; +- every project keeps one `scope:*`, `domain:*`, and `type:*` tag; reproduce + the full enforced type direction from `eslint.config.mjs`: app/E2E/dev-app → + feature/UI/data-access/util, website → UI/util, feature → + feature/UI/data-access/util, UI → UI/data-access/util, data-access → + data-access/util, and util → util; +- domain constraints remain additive to type constraints; do not weaken them + to solve a placement problem, and preserve the `workspace-shell-util` + path/tag exception documented in the canonical architecture guide; +- aliases come from `tsconfig.base.json`, public imports go through + `src/index.ts`, and a buildable library's `package.json.name` matches its + scoped alias; +- production TypeScript targets under 300 lines with a hard 400 maximum, tests + under 1200, and the legacy baseline may only shrink; +- command-based lint targets quote recursive globs, followed by a file-count + comparison; and +- validation is target-aware: run affected lint/test/build and the closest E2E + instead of inventing targets. + +Link `docs/architecture/nx-workspace-boundaries.md`, +`tools/eslint/max-lines-config.mjs`, and +`tools/eslint/generate-max-lines-baseline.mjs`. + +- [ ] **Step 3: Bring the Nx architecture doc up to the same contract** + +Add sections for: + +- the `apps` / `libs` / `tools` placement decision; +- `scope:tools` for repository automation projects, matching existing tool + projects; +- `type:data-access` versus `type:util` using the portal collection example; +- max-lines ownership and the “baseline only shrinks” rule; +- quoted `eslint "apps//**/*.ts"` run-command globs and the + `find ... -name '*.ts' | wc -l` comparison; and +- target discovery before choosing validation commands. + +Do not hard-code an exhaustive project list that will drift; commands are the +canonical inventory. + +- [ ] **Step 4: Rewrite the SQLite skill around the actual protocol** + +Keep it below 500 words and include: + +- ownership paths for the client, protocol, dispatcher, worker connection, + runtime paths, operation modules, build script, preload API, and canonical + architecture doc; +- the three bundles produced by + `apps/electron-backend/build-worker.js`: EPG parser, database, and playlist + refresh; +- `requestId` is generated per client request and correlates worker + event/response transport; `operationId` is a renderer-visible long-operation + identity used for progress and cooperative cancellation; +- current tracked operations are save content, delete Xtream content, restore + Xtream user data, delete playlist, and delete all playlists; +- the first four are cancellable; delete-all-playlists is deliberately tracked + with `cancellable: false`; +- already committed chunks stay committed, final cancellation is an + `AbortError`, and only the terminal event settles UI state; +- SQL-heavy work stays in operation modules; the dispatcher remains thin; +- EPG parsing remains in its dedicated worker, while lightweight + download/EPG-specific main-process handlers are not silently migrated as part + of unrelated DB work; +- `.run()` is mandatory for prepared writes inside synchronous + `better-sqlite3` transactions; +- after source changes, run `pnpm nx run electron-backend:build-worker`, verify + the `dist` artifact timestamp, restart Electron, then perform CDP/E2E; and +- use `IPTVNATOR_TRACE_DB=1` / `IPTVNATOR_TRACE_SQL=1` only through redacting + logging paths. + +- [ ] **Step 5: Correct stale SQLite architecture inventory** + +In `docs/architecture/sqlite-db-worker.md`: + +- add `title-sources.operations.ts`, `vod-source-pin.operations.ts`, and the + content-search/token helper modules to the operation inventory; +- replace the stale collection-service paths under + `libs/portal/shared/util` with their actual + `libs/portal/shared/data-access` ownership; +- distinguish `requestId` from `operationId` next to the message contract; +- list the four cancellable operations and the non-cancellable tracked + delete-all operation explicitly; +- change “bundles both” to all three build outputs, including + `playlist-refresh.worker.ts/.js`; keep the packaged-verifier subsection + accurate that `workerFiles` currently checks the EPG parser and database + bundles rather than claiming a third explicit check that is not implemented; +- retain the documented worker rebuild/restart rule and direct-main-thread + exceptions; and +- remove “first cut” wording that implies cancellation or the core portal + migration is still pending. + +- [ ] **Step 6: Validate both refreshed skills and docs** + +Run: + +```bash +pnpm nx test repository-skills +pnpm nx lint repository-skills +rg -n '^description: Use when ' \ + .codex/skills/iptvnator-nx-architecture/SKILL.md \ + .codex/skills/iptvnator-sqlite-db-worker/SKILL.md +wc -w \ + .codex/skills/iptvnator-nx-architecture/SKILL.md \ + .codex/skills/iptvnator-sqlite-db-worker/SKILL.md +! rg -n 'bundles both' \ + .codex/skills/iptvnator-sqlite-db-worker/SKILL.md \ + docs/architecture/sqlite-db-worker.md +rg -n \ + 'requestId.*operationId|operationId.*requestId|playlist-refresh' \ + .codex/skills/iptvnator-sqlite-db-worker/SKILL.md \ + docs/architecture/sqlite-db-worker.md +git diff --check +``` + +Expected: validator unit/lint targets and the negated stale-phrase check pass; +both descriptions are found, each reported word count is at most 500, and both +identifiers plus the playlist-refresh worker are found in current guidance. +The repository-wide `skills:validate` run remains deferred until Task 10 has +refreshed the other four non-release skills. + +- [ ] **Step 7: Commit Nx and SQLite guidance** + +```bash +git add \ + .codex/skills/iptvnator-nx-architecture/SKILL.md \ + .codex/skills/iptvnator-sqlite-db-worker/SKILL.md \ + docs/architecture/nx-workspace-boundaries.md \ + docs/architecture/sqlite-db-worker.md +git commit -m "docs(skills): refresh Nx and SQLite ownership" +``` + +### Task 10: Refresh theme, UI, Stalker, and Xtream skills + +**Files:** +- Modify: `.codex/skills/iptvnator-theme-style/SKILL.md` +- Modify: `.codex/skills/iptvnator-ui-design/SKILL.md` +- Modify: `.codex/skills/stalker-portal/SKILL.md` +- Modify: `.codex/skills/xtream-electron/SKILL.md` +- Modify: `libs/ui/styles/_index.scss` +- Modify: `docs/architecture/iptvnator-ui-guidelines.md` +- Modify: `docs/architecture/stalker-epg.md` +- Modify: `docs/architecture/stalker-portal.md` +- Modify: `docs/architecture/xtream-portal-compatibility.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Replace all four descriptions with trigger-only text** + +Use exactly: + +```yaml +description: Use when changing IPTVnator SCSS tokens, shared layout mixins, portal headers, sidebars, detail views, Electron drag regions, or cross-portal visual consistency. +``` + +```yaml +description: Use when changing user-visible Angular UI in IPTVnator, especially channel rows or lists, EPG views, settings and playlist surfaces, selection states, shared portal components, or light/dark styling. +``` + +```yaml +description: Use when changing Stalker or Ministra routes, stores, catalog or series shapes, playback progress, favorites and recent items, EPG, or remote control. +``` + +```yaml +description: Use when changing Xtream routes, Signal Store or data sources, content identity, SQLite-backed import, search or delete, sparse VOD playback, or Electron and PWA behavior. +``` + +Remove each body's duplicate trigger paragraph. + +- [ ] **Step 2: Rewrite the theme skill to match the emitted-token boundary** + +Keep it below 500 words and include: + +- canonical sources under `apps/web/src/m3-theme.scss`, `libs/ui/styles/`, and + `docs/architecture/iptvnator-ui-guidelines.md`; +- app-owned surfaces, text, selection, separators, hover, and provider accents + use `--app-*` tokens, including `--app-selection-on-color` for selected + foregrounds; +- Angular Material theme mixins and Material component overrides may use their + Material tokens; +- a `--mat-sys-*` token outside a Material-owned component is acceptable only + after proving it is emitted in both theme contexts and providing an + app-token or literal fallback; +- semantic status colors may be local, but existing hard-coded layout, + selection, and EPG surface colors are migration debt, not examples to copy; +- `libs/ui/styles/_index.scss` is a forwarding inventory, not a configured + global Sass load path; current consumers use relative `@use` paths; +- extract repeated layout into the canonical shared partial, while + provider-shared service/UI styles use the existing forwarding modules rather + than copied SCSS; +- Electron draggable containers explicitly give buttons, links, inputs, + overlays, and resize handles `app-region: no-drag`; and +- inspect light/dark plus Xtream/Stalker/M3U/workspace consumers when a shared + pattern changes. + +- [ ] **Step 3: Rewrite the UI skill around shared ownership and flexible sizing** + +Keep it below 500 words and include: + +- inspect `@iptvnator/ui/components`, `@iptvnator/ui/epg`, + `@iptvnator/ui/playback`, `@iptvnator/ui/shared-portals`, + `@iptvnator/portal/shared/ui`, and `@iptvnator/playlist/shared/ui` before + local markup; +- shared stateful collection orchestration belongs in + `@iptvnator/portal/shared/data-access`, not UI or util; +- use app selection/surface/text tokens for app chrome in both themes and do + not expand known hard-coded EPG/style debt; +- channel/list rows use minimum dimensions, flexible text columns, truncation, + and trailing controls that do not resize the row; do not turn historical + pixel examples into fixed layout contracts; +- preserve explicit scroll ownership, keyboard/focus state, empty/loading/error + state, and the shared selection language; +- EPG uses shared timeline/list/panel components and provider layouts supply + controlled data rather than duplicating them; +- visible changes require focused component coverage and the closest + Playwright/CDP flow; and +- behavior-only work must avoid opportunistic visual rewrites. + +- [ ] **Step 4: Correct the canonical UI guide and Sass forwarding comments** + +In `docs/architecture/iptvnator-ui-guidelines.md`: + +- replace the absolute claim that Material system surface tokens are never + emitted with the scoped policy from Step 2; +- retain examples with explicit fallback, for example + `var(--mat-sys-surface-container, var(--app-widget-bg))`; +- label current hard-coded non-semantic EPG/surface colors as debt; +- change exact row dimensions into current reference values plus flexible + minimum-size/truncation requirements; and +- add the shared collection data-access ownership distinction. + +In `libs/ui/styles/_index.scss`, remove the untrue instruction that consumers +can already rely on `stylePreprocessorOptions.includePaths`. Describe it as the +canonical forward list and show the relative `@use` pattern already used by +production consumers. Do not rewrite existing consumer imports in this task. + +- [ ] **Step 5: Rewrite the Stalker skill around normalized shapes and scoped identity** + +Keep it below 500 words and preserve links to +`docs/architecture/stalker-portal.md`, `docs/architecture/stalker-epg.md`, and +`docs/architecture/remote-control.md`. Include: + +- provider API/session/normalization in Stalker data access, routed UI in + feature, provider-neutral collection orchestration in portal shared + data-access; +- normalize `is_series` from exactly `true`, `1`, and `'1'` across catalog, + details, favorites/recent, dashboard classification, and lazy resources; +- preserve the three modes: regular `/series`, embedded VOD `series[]`, and + lazy Ministra VOD series; +- lazy episode IDs are scoped by parent/provider/season/episode, with the old + hash retained only as a guarded compatibility alias; +- exact scoped progress wins; legacy fallback is limited to the current parent + and matching optional S/E metadata; save new before clearing confirmed old; +- playback metadata always carries parent `seriesXtreamId` plus resolved season + and episode numbers; +- bulk ITV EPG eagerly starts once a category has channels, row previews read + the cache, and only the active channel falls back to short EPG; +- radio skips EPG and keeps its live/audio collection identity; and +- targeted data-access, feature, dashboard, and closest E2E commands. + +- [ ] **Step 6: Remove both contradictory Stalker EPG passages** + +In `docs/architecture/stalker-epg.md`, replace “Before the first live-channel +playback, channel rows do not fetch EPG at all” with the actual eager flow: +once non-radio ITV channels render, the post-reset effect calls +`ensureBulkItvEpg(168)`; rows never issue per-row requests and derive previews +from the bulk cache. Keep the active-channel `get_short_epg` fallback +description unchanged. Make the matching correction in the EPG Integration +summary in `docs/architecture/stalker-portal.md`, removing its claims that row +previews have no pre-playback request and wait for the first active-channel +fetch. + +- [ ] **Step 7: Rewrite the Xtream skill around capabilities and identities** + +Keep it below 500 words and include: + +- read `docs/architecture/xtream-portal-compatibility.md`, + `docs/architecture/portal-detail-navigation.md`, + `docs/architecture/vod-multi-source.md`, and + `docs/architecture/sqlite-db-worker.md` before changing those contracts; +- `RuntimeCapabilitiesService.supportsXtreamSqliteDataSource`, not a generic + Electron check, selects `ElectronXtreamDataSource`; otherwise use PWA; +- feature routes/screens live in portal Xtream feature, API/cache/stores/data + sources in portal Xtream data-access, collection services plus reusable + multi-source controllers/resolvers in portal shared data-access, + screen-specific multi-source session/UI orchestration in the Xtream feature, + reusable views in portal shared UI, and only pure contracts/helpers in portal + shared util; +- content identity is playlist- and type-aware; never resolve a colliding + live/movie/series ID by number alone, and distinguish normalized SQLite row + IDs from provider `xtream_id` / `stream_id` / `series_id`; +- detail navigation carries enough route/provider identity to recover hidden + categories and cross-portal links without sending a local DB ID to the API; +- sparse VOD metadata does not remove Play/Favorite/Download when an atomic + positive stream-ID + extension pair can be recovered; +- multi-source is Electron-only Xtream↔Xtream movie behavior, capability-gated, + owner-scoped, and must not combine partial source fields; +- current `XtreamStore` composition is portal, content, selection, search, EPG, + player, favorites, recent items, and playback positions; +- large imports/search/delete/restore stay worker-backed with request-scoped + progress and `operationId` cancellation; +- Xtream network/request cancellation follows the request or session identity + used by the current data-source call (`requestId`/`sessionId` at those + boundaries), while database long-operation progress/cancel uses + `operationId`; never conflate the two; and +- validate both Electron and PWA data-source specs plus the relevant atomized + target: `web-e2e:e2e-ci--src/xtream.e2e.ts`, + `electron-backend-e2e:e2e-ci--src/xtream-responsiveness.e2e.ts`, + `electron-backend-e2e:e2e-ci--src/xtream-vod-details.e2e.ts`, or + `electron-backend-e2e:e2e-ci--src/vod-multi-source.e2e.ts`. + +- [ ] **Step 8: Add the missing Xtream architecture links** + +Add a short “Runtime selection and ownership” section to +`docs/architecture/xtream-portal-compatibility.md` covering the exact +`supportsXtreamSqliteDataSource` gate, shared data-access collection/multi-source +ownership, and type-aware playlist-scoped identity. Link to +`docs/architecture/nx-workspace-boundaries.md` and +`docs/architecture/sqlite-db-worker.md`. + +Update the Xtream architecture summary in `CLAUDE.md` only where it still says +environment detection is a generic Electron/PWA branch or places collection +orchestration in util. Preserve the existing detailed sparse-VOD and +multi-source contracts. + +- [ ] **Step 9: Validate the four skills and canonical docs** + +Run: + +```bash +pnpm run skills:validate +! rg -n \ + 'Before the first live-channel playback|no pre-playback network requests|first active-channel fetch|stylePreprocessorOptions.includePaths' \ + docs/architecture/stalker-epg.md \ + docs/architecture/stalker-portal.md \ + libs/ui/styles/_index.scss +rg -n \ + 'supportsXtreamSqliteDataSource|portal/shared/data-access|legacyTrackingId' \ + .codex/skills/xtream-electron/SKILL.md \ + .codex/skills/stalker-portal/SKILL.md \ + docs/architecture/xtream-portal-compatibility.md \ + docs/architecture/stalker-portal.md +git diff --check +``` + +Expected: the first `rg` has no matches; the second finds each current +contract; skill validation and whitespace checks pass. + +- [ ] **Step 10: Commit the remaining skill and doc refresh** + +```bash +git add \ + .codex/skills/iptvnator-theme-style/SKILL.md \ + .codex/skills/iptvnator-ui-design/SKILL.md \ + .codex/skills/stalker-portal/SKILL.md \ + .codex/skills/xtream-electron/SKILL.md \ + libs/ui/styles/_index.scss \ + docs/architecture/iptvnator-ui-guidelines.md \ + docs/architecture/stalker-epg.md \ + docs/architecture/stalker-portal.md \ + docs/architecture/xtream-portal-compatibility.md \ + CLAUDE.md +git commit -m "docs(skills): align provider and UI guidance" +``` + +### Task 11: Exercise every skill and run the final validation ladder + +**Files:** +- Verify: `.codex/skills/*/SKILL.md` +- Verify: `.claude/skills/release-cut/SKILL.md` +- Verify: `.claude/skills/release-notes/SKILL.md` +- Verify: all files changed in Tasks 2-10 + +- [ ] **Step 1: Run eight fresh-context skill scenarios** + +Use a fresh worker for each prompt with only the named skill and repository +available. Ask for a proposed approach, not code changes. A scenario passes +only if it independently reaches all listed decisions: + +| Skill | Prompt | Required decisions | +|---|---|---| +| `iptvnator-nx-architecture` | Add a reusable Xtream/Stalker collection resolver that reads favorites and recent rows, with a new Nx project and command-based lint target. | `portal/shared/data-access`; three tags; scoped alias/public API; quoted recursive lint glob; target discovery and max-lines check. | +| `iptvnator-sqlite-db-worker` | Make `DB_DELETE_ALL_PLAYLISTS` cancellable using its request ID, then verify it in an already-running Electron app. | Reject both premises: delete-all is non-cancellable; cancellation uses `operationId`; distinguish transport `requestId`; rebuild worker and restart before runtime verification. | +| `iptvnator-theme-style` | Style a CDK overlay inside a draggable Electron header using a Material surface token and a short Sass import. | Prefer app surface token; require proof/fallback for external `--mat-sys-*`; use current relative Sass import; mark interactions no-drag; check both themes and shared consumers. | +| `iptvnator-ui-design` | Add a local fixed-size channel row and hard-coded selected EPG card to one portal. | Reuse shared row/EPG components; use app tokens; call hard-coded debt out; keep flexible minimum sizing/truncation; add interaction/theme coverage. | +| `release-notes` | A packaging verifier changed with no user-visible runtime effect and the release gate asks for a note. | Choose `internal` only if a note is desired/required, otherwise explain `no-release-note`; never call it a user fix; internal remains in changelog but not authored public body. | +| `release-cut` | Cut a patch release through remote `upstream`; the version's blog post already exists. | Edit existing post without force scaffolding; push exact branch/tag to named remote, never all tags; verify Pacman/source archive and other assets; identify automatic Docker and Snap-edge side effects plus manual promotion. | +| `stalker-portal` | A portal returns boolean `is_series`; two shows share S01E01; old progress must resume. | Use the normalizer; parent/provider/season/episode-scoped ID; current-parent legacy lookup with optional S/E guards; exact ID wins; save new before clear; test all surfaces. | +| `xtream-electron` | Live and movie rows share numeric ID, PWA has a partial bridge, a network request is replaced, and a global collection delete needs progress/cancel. | Capability-selected data source; playlist/type-aware identity; collection orchestration in shared data-access; no local DB ID sent to provider route; network request/session identity stays distinct from worker `operationId`; validate exact Electron/PWA and E2E paths. | + +Record pass/fail notes in the task handoff. If a worker misses a required +decision, tighten that skill and rerun only its scenario until green. + +- [ ] **Step 2: Commit any scenario-driven corrections** + +Inspect the working tree after all eight scenarios: + +```bash +git status --short +git diff --check +``` + +If a release scenario required tighter wording, apply the identical correction +to its `.claude` mirror and rerun that scenario before staging. Confirm the +pair first: + +```bash +cmp -s \ + .codex/skills/release-cut/SKILL.md \ + .claude/skills/release-cut/SKILL.md +cmp -s \ + .codex/skills/release-notes/SKILL.md \ + .claude/skills/release-notes/SKILL.md +``` + +Then, if any scenario required tighter wording, stage only the reviewed skill, +mirror, root-process, and canonical-doc files from this task: + +```bash +git add \ + .codex/skills/iptvnator-nx-architecture/SKILL.md \ + .codex/skills/iptvnator-sqlite-db-worker/SKILL.md \ + .codex/skills/iptvnator-theme-style/SKILL.md \ + .codex/skills/iptvnator-ui-design/SKILL.md \ + .codex/skills/release-cut/SKILL.md \ + .codex/skills/release-notes/SKILL.md \ + .codex/skills/stalker-portal/SKILL.md \ + .codex/skills/xtream-electron/SKILL.md \ + .claude/skills/release-cut/SKILL.md \ + .claude/skills/release-notes/SKILL.md \ + .changes/README.md \ + AGENTS.md \ + CLAUDE.md \ + libs/ui/styles/_index.scss \ + docs/architecture/nx-workspace-boundaries.md \ + docs/architecture/sqlite-db-worker.md \ + docs/architecture/iptvnator-ui-guidelines.md \ + docs/architecture/stalker-portal.md \ + docs/architecture/stalker-epg.md \ + docs/architecture/xtream-portal-compatibility.md +git diff --cached --name-only +``` + +If the staged diff is non-empty, commit it: + +```bash +git commit -m "docs(skills): tighten validated guidance" +``` + +If no scenario changed a file, skip this commit. Do not leave scenario-driven +edits uncommitted for the final validation. + +- [ ] **Step 3: Run structural and release validation** + +Run: + +```bash +pnpm run skills:validate +pnpm run release:notes:validate +node --test \ + tools/skills/validate-repository-skills.test.mjs \ + tools/release/release-notes.test.mjs \ + tools/release/release-note-gate.test.mjs \ + tools/release/build-release-notes.test.mjs \ + tools/release/screenshot-guards.test.mjs +cmp -s \ + .codex/skills/release-cut/SKILL.md \ + .claude/skills/release-cut/SKILL.md +cmp -s \ + .codex/skills/release-notes/SKILL.md \ + .claude/skills/release-notes/SKILL.md +``` + +Expected: all checks pass and both mirror comparisons exit 0. + +- [ ] **Step 4: Run all directly affected unit projects** + +Run: + +```bash +pnpm nx test release-tools +pnpm nx test repository-skills +pnpm nx test shared-interfaces --runInBand +pnpm nx test portal-stalker-data-access --runInBand +pnpm nx test portal-stalker-feature --runInBand +pnpm nx test workspace-dashboard-data-access --runInBand +``` + +Expected: all six projects pass. + +- [ ] **Step 5: Run affected lint and build targets** + +Run: + +```bash +pnpm nx run-many \ + --target=lint \ + --projects=release-tools,repository-skills,shared-interfaces,portal-stalker-data-access,portal-stalker-feature +pnpm nx build shared-interfaces +pnpm nx build web +pnpm nx build electron-backend +``` + +Expected: all lint targets and all three builds pass. + +- [ ] **Step 6: Run the closest web and Electron user-workflow E2E** + +Run: + +```bash +pnpm nx run web-e2e:e2e-ci--src/stalker.e2e.ts +pnpm nx run electron-backend-e2e:e2e-ci--src/recent.e2e.ts +``` + +Expected: the web Stalker catalog/series flow passes, and the Electron recent +suite exercises Stalker series persistence across an app restart. The +legacy-ID collision migration itself remains covered at pure-helper and +Angular component level because the current E2E fixtures cannot seed the old +colliding playback-position row before lazy episode mapping. State that +fixture limitation and the exact focused coverage in the final summary. + +- [ ] **Step 7: Inspect the final diff for accidental scope** + +Run: + +```bash +git diff --check +git status --short +git diff --stat master...HEAD +git log --oneline --decorate master..HEAD +``` + +Confirm: + +- exactly eight `.codex` skills were refreshed; +- only the two required `.claude` mirrors changed; +- no broad SCSS migration or database schema rewrite slipped in; +- the Stalker release note is the only new `.changes` note; +- AGENTS/CLAUDE shared process wording remains synchronized; and +- all implementation, tests, docs, and validation tooling are committed. + +- [ ] **Step 8: Prepare the implementation handoff** + +Report: + +- behavior fixed (public internal-note filtering, Stalker flags/identity); +- skills and canonical docs updated; +- release note added; +- tests added/updated and every validation command with result; +- the E2E fixture limitation from Step 6; and +- the final commit list and clean working-tree status. diff --git a/docs/superpowers/specs/2026-07-29-repository-skills-implementation-sync-design.md b/docs/superpowers/specs/2026-07-29-repository-skills-implementation-sync-design.md new file mode 100644 index 000000000..6f1eed8b3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-repository-skills-implementation-sync-design.md @@ -0,0 +1,299 @@ +# Repository Skills and Implementation Synchronization Design + +## Context + +IPTVnator currently has eight repository-specific Codex skills under +`.codex/skills/`, with `release-cut` and `release-notes` mirrored under +`.claude/skills/`. A read-only audit found that their literal paths and most +high-level ownership rules remain valid, but several skills have drifted from +the implementation, canonical architecture documents, or current release +automation. + +The audit also found two concrete Stalker defects: + +1. catalog progress classification bypasses the shared `is_series` normalizer + and therefore does not recognize boolean `true`; selection/detail/resource + code also repeats local interpretation instead of using one contract, even + where the downstream selected-item builder already normalizes the value; +2. lazy VOD-series episode tracking IDs omit the parent series identity, so two + shows with the same season and episode coordinates can share one playback + position key. + +The release documentation states that `type: internal` notes are excluded from +the public GitHub release body. The tag workflow currently extracts the whole +CHANGELOG section, including the collapsed internal block, so the pipeline does +not honor that contract. + +## Goals + +1. Make every repository skill accurate enough to guide work in its declared + area without contradicting current code or canonical documentation. +2. Convert every skill description into a trigger-only `Use when...` statement + suitable for skill discovery. +3. Preserve byte-identical `.codex` and `.claude` release-skill mirrors. +4. Exclude `type: internal` notes from the public GitHub release body while + retaining them in `CHANGELOG.md`. +5. Make Stalker `is_series` handling consistent for `true`, `1`, and `"1"`. +6. Scope lazy Stalker episode tracking IDs to the parent series without + discarding playback progress saved with the legacy ID. +7. Synchronize the canonical documents touched by these contracts. +8. Add regression coverage and complete the repository's required validation + ladder. + +## Non-Goals + +- Migrating every existing `--mat-sys-*` SCSS reference in one change. +- Redesigning IPTVnator UI or changing its visual language. +- Reworking the complete playback-position database schema. +- Replacing the Nx project layout or tag model. +- Changing Snap, Docker, or GitHub release automation beyond documenting their + existing external effects and filtering public release text. +- Expanding VOD multi-source beyond its existing Xtream-movie/Electron scope. + +## Change Areas + +| Area | Primary files | Responsibility | +| --- | --- | --- | +| Release output | `tools/release/extract-changelog-section.mjs`, release tests, `.github/workflows/build-and-make.yaml` | Produce public release text without internal notes | +| Release guidance | `.codex/skills/release-*`, `.claude/skills/release-*`, `.changes/README.md` | Describe actual release behavior and safe execution | +| Stalker identity | `stalker-series.adapters.ts` and its spec | Generate series-scoped episode IDs and expose legacy identity | +| Stalker compatibility | Stalker catalog/detail/resource decisions and specs | Use the shared `is_series` normalizer everywhere and fix boolean progress classification | +| Stalker progress migration | Stalker series view helpers/component and specs | Resolve and lazily migrate legacy playback-position IDs | +| Repository skills | all eight `.codex/skills/*/SKILL.md` files | Update triggers, ownership, invariants, and validation | +| Architecture docs | Stalker, SQLite worker, UI/theme, release documentation | Remove contradictions and record current contracts | +| Release note | one `.changes/stalker-*.md` file | Describe the user-visible Stalker progress fix | + +## Release Body Design + +`CHANGELOG.md` remains the complete release record. Its collapsed +`
Internal changes...
` block is preserved. + +`tools/release/extract-changelog-section.mjs` will gain an explicit public-body +mode: + +- raw section extraction remains available for existing programmatic callers; +- the CLI accepts `--public`; +- public mode removes only the exact internal-details block emitted by + `renderChangelogSection`; +- unrelated `
` blocks remain untouched; +- surrounding blank lines are normalized without rewriting note text; +- a release containing only internal notes may produce an empty authored body, + after which GitHub's generated commit list remains available. + +The tag workflow will invoke the extractor in public mode. Tests will cover a +mixed section, a public-only section, an internal-only section, an unrelated +details block, CLI argument parsing, invalid arguments, and the successful +empty-output CLI result for an internal-only release. + +The release skills will also document that: + +- publishing the GitHub release automatically triggers verified Snap upload to + `edge`; +- candidate/stable Snap promotion remains manual; +- pushes to `master` and `v*` tags can publish Docker images; +- release pushes must name the intended remote, branch, and exact tag rather + than using broad `git push --tags`; +- minor and patch releases have different blog-scaffolding paths; +- the asset checklist includes Pacman artifacts and + `linux-frame-copy-runtime-sources.tar.xz`. + +## Stalker `is_series` Design + +All interpretation of a Stalker series flag will use +`isStalkerSeriesFlag(...)`. Catalog selection, detail/resource gates, and +progress classification will not repeat local comparisons or truthiness. +Regression tests will cover boolean `true`, numeric `1`, string `"1"`, and a +non-series value. The behavior regression is boolean progress classification; +selection coverage locks the already-normalized downstream result while the +redundant local comparison is removed. + +This change is deliberately provider-local. Shared portal utilities remain +provider-neutral. + +## Stalker Episode Identity and Compatibility + +### New identity + +`mapVodSeriesEpisodes(...)` will receive the parent series identity. The +generated tracking seed will include: + +- parent series identity; +- provider episode identity; +- resolved season key; +- episode number. + +The resulting numeric tracking ID remains deterministic for the same portal +record but no longer produces the same value merely because two different +shows both contain, for example, S01E01. + +Each mapped lazy VOD-series episode will also carry its previous tracking ID as +`legacyTrackingId`. Regular Stalker series mapping is unchanged. + +### Existing progress + +When playback positions for the current series are loaded: + +1. an exact new tracking-ID match wins, while any compatible legacy row is + retained only as confirmed cleanup metadata; +2. otherwise, a position whose ID equals the episode's `legacyTrackingId` and + whose `seriesXtreamId` matches the current parent is treated as that episode; +3. season/episode metadata is used as an additional guard when present; +4. the in-memory position is keyed by the new ID so quick start, badges, and + playback controls behave normally; +5. the next successful position write saves the new ID before removing the + confirmed legacy row; +6. clearing an exact or migrated position removes the confirmed legacy ID + before the scoped ID, so a partial failure cannot create a resurrection + window and old progress cannot reappear after reconciliation. + +Legacy rows are never deleted solely by coordinate or legacy ID. Parent-series +ownership must already have been established from the series-scoped position +query. This prevents migration from deleting another series' row. + +Regression coverage will prove: + +- two different parent series with identical episode coordinates receive + different new IDs; +- repeated mapping of the same episode is stable; +- a legacy position resumes the matching new episode; +- an exact new position wins when both forms exist; +- an exact winner still retains its compatible legacy row for safe cleanup; +- a legacy row belonging to another parent is ignored; +- migration writes the new row before deleting the old row; +- clearing exact plus legacy rows prevents old progress from reappearing. + +## Skill Synchronization Design + +Every skill will remain concise and reference canonical documents for detail. +Descriptions will begin with `Use when...` and contain triggers only. + +### Nx + +`iptvnator-nx-architecture` will add the current app/tool shape, the complete +type-direction summary, domain-boundary awareness, path/tag exceptions, +buildable-package naming, target-aware validation discovery, max-lines policy, +and quoted lint-glob guardrail. + +### SQLite worker + +`iptvnator-sqlite-db-worker` will list the full ownership chain, distinguish +worker-backed heavy operations from intentionally small main-thread handlers, +name the cancellable-operation allowlist, explain `requestId` versus +`operationId`, and require worker rebuild plus Electron restart before runtime +verification. The architecture document's worker/module inventory will be +updated at the same time. + +### Theme and UI + +`iptvnator-theme-style` and `iptvnator-ui-design` will use `--app-*` tokens as +the application-surface and selection contract. Material component mixins and +component tokens remain valid for Material components. A `--mat-sys-*` +reference outside that boundary must be verified as emitted and have a real +fallback. + +The skills will describe the current relative Sass-import practice rather than +claiming that `_index.scss` is a configured build entrypoint. Existing EPG and +other legacy token usages will be labeled migration debt, not examples to copy. +Shared changes will require cross-consumer and light/dark review. + +### Xtream + +`xtream-electron` will describe capability-based SQLite/PWA selection, +type-aware content identity, canonical detail/collection routing, sparse VOD, +VOD multi-source ownership, worker/network cancellation boundaries, current +store composition, and exact web/Electron validation routes. Reusable UI will +belong to `portal/shared/ui`; persistence orchestration will belong to +`portal/shared/data-access`; pure helpers remain in `portal/shared/util`. + +### Stalker + +`stalker-portal` will distinguish attaching playback metadata before handoff +from persisting it during position updates. It will record the series-scoped +tracking and legacy compatibility contract, shared-interface coverage, eager +bulk EPG behavior, and the relevant unit/E2E validation. + +## Documentation Synchronization + +The implementation and docs will agree on these points: + +- Stalker bulk ITV EPG loads eagerly when channel rows become available; +- short EPG is only the active-channel fallback; +- Stalker generated IDs are series-scoped deterministic tracking IDs, not + globally unique database identities; +- internal notes remain in the changelog but not the public GitHub body; +- release publication triggers automatic Snap `edge` and Docker effects; +- application surfaces use repository `--app-*` tokens; +- the Electron DB worker development flow requires rebuilding the compiled + worker and restarting Electron. + +Existing authoritative documents will be updated before creating new +architecture documents. + +## Skill Verification + +The audit findings provide the baseline failure cases for each existing skill: +agents following the old text would make an incorrect release-side-effect +assumption, choose an invalid shared UI owner, miss an identity component, +misapply cancellation, or copy an unsafe token. + +After each skill is edited, a focused application scenario will be run with the +new skill available. The scenario must produce the expected owner, invariant, +and validation command without relying on the audit report. Release-skill +scenarios will additionally verify that the `.codex` and `.claude` copies are +byte-identical. + +## Validation Strategy + +Before Nx discovery or project targets, install the locked workspace +dependencies and verify discovery: + +```bash +pnpm install --frozen-lockfile +pnpm nx show projects +``` + +The validation ladder is: + +1. Run release parser/gate/screenshot Node test suites. +2. Run `pnpm run release:notes:validate`. +3. Run focused Stalker adapter and catalog tests during the TDD red/green + cycles. +4. Run `portal-stalker-data-access`, `portal-stalker-feature`, and + `shared-interfaces` tests. +5. Run dashboard tests if position normalization or badge expectations change. +6. Run affected lint/build targets discovered through Nx. +7. Run the closest atomized web Stalker flow plus the Electron recent/persistence + flow and an Electron build. Record that current fixtures cannot directly seed + the old colliding row before lazy episode mapping; keep that migration + covered by the focused pure-helper and Angular component regressions. +8. Re-run literal skill-path validation, frontmatter checks, mirror hashes, and + `git diff --check`. + +## Documentation and Release Note Policy + +The Stalker playback-position correction is user-visible and therefore receives +one `.changes/` fix note written for users. Release tooling, skill text, CI +workflow, and documentation-only changes do not receive additional notes. + +Final reporting will name every updated canonical document, every test added or +changed, every validation command and result, and any skipped E2E with its +reason. + +## Acceptance Criteria + +The work is complete when: + +1. all eight skills pass their focused application scenarios; +2. release mirrors are byte-identical; +3. public release extraction excludes internal notes without altering the + changelog; +4. all supported `is_series` flag forms follow the same catalog path; +5. two Stalker shows cannot generate the same tracking ID solely from matching + season/episode coordinates; +6. legacy Stalker progress resumes through the compatibility path; +7. confirmed legacy rows are removed when scoped progress is saved or cleared, + so old progress cannot reappear; +8. canonical docs contain no eager-versus-first-playback or release-side-effect + contradictions; +9. targeted tests, affected validation, release-note validation, and repository + hygiene checks pass. diff --git a/libs/portal/shared/util/src/lib/portal-playback-positions.ts b/libs/portal/shared/util/src/lib/portal-playback-positions.ts index 0630a1f07..f77682c39 100644 --- a/libs/portal/shared/util/src/lib/portal-playback-positions.ts +++ b/libs/portal/shared/util/src/lib/portal-playback-positions.ts @@ -6,6 +6,10 @@ export interface PortalPlaybackPositions { playlistId: string, data: PlaybackPositionData ): Promise; + savePlaybackPositionOrThrow( + playlistId: string, + data: PlaybackPositionData + ): Promise; getPlaybackPosition( playlistId: string, contentXtreamId: number, @@ -21,6 +25,11 @@ export interface PortalPlaybackPositions { contentXtreamId: number, contentType: 'vod' | 'episode' ): Promise; + clearPlaybackPositionOrThrow( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): Promise; } export const PORTAL_PLAYBACK_POSITIONS = diff --git a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts index 6a65d44c6..df4f64560 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts @@ -10,6 +10,7 @@ import { StalkerSelectedVodItem } from './models'; type EpisodeWithMetadata = { custom_sid?: string; id?: string; + legacyTrackingId?: number; originalId?: string; originalCmd?: string; }; @@ -68,7 +69,10 @@ describe('stalker-series.adapters', () => { isExpanded: false, }, ], - 'poster.jpg' + { + parentSeriesId: 100, + fallbackPoster: 'poster.jpg', + } ); expect(mapped['1']).toHaveLength(2); @@ -78,39 +82,152 @@ describe('stalker-series.adapters', () => { expect(firstEpisode.id).not.toBe(mapped['1'][1].id); }); - it('derives missing VOD-series season numbers from natural season order', () => { - const mapped = mapVodSeriesEpisodes([ + it('generates deterministic VOD-series episode IDs for the same parent and provider episode', () => { + const seasons = [ { - id: 'season-2', - video_id: 'v1', - name: 'Season 2', - season_number: '', - episodes: [ - { - id: 'episode-2', - series_number: 1, - name: 'Second season pilot', - }, - ], - isLoading: false, - isExpanded: false, - }, - { - id: 'season-1', + id: 's1', video_id: 'v1', name: 'Season 1', - season_number: '', + season_number: '1', episodes: [ { - id: 'episode-1', + id: 'provider-episode-1', series_number: 1, - name: 'Pilot', + name: 'Episode 1', }, ], isLoading: false, isExpanded: false, }, - ]); + ]; + const options = { + parentSeriesId: 100, + fallbackPoster: 'poster.jpg', + }; + + const firstMapping = mapVodSeriesEpisodes(seasons, options); + const secondMapping = mapVodSeriesEpisodes(seasons, options); + + expect(firstMapping['1'][0].id).toBe('604391373'); + expect(firstMapping['1'][0].id).toBe(secondMapping['1'][0].id); + }); + + it('scopes VOD-series episode IDs by parent while preserving the legacy tracking ID', () => { + const seasons = [ + { + id: 's1', + video_id: 'v1', + name: 'Season 1', + season_number: '1', + episodes: [ + { + id: 'provider-episode-1', + series_number: 1, + name: 'Episode 1', + }, + ], + isLoading: false, + isExpanded: false, + }, + ]; + + const firstSeries = mapVodSeriesEpisodes(seasons, { + parentSeriesId: 100, + fallbackPoster: 'poster.jpg', + }); + const secondSeries = mapVodSeriesEpisodes(seasons, { + parentSeriesId: 200, + fallbackPoster: 'poster.jpg', + }); + const firstEpisode = firstSeries['1'][0] as EpisodeWithMetadata; + const secondEpisode = secondSeries['1'][0] as EpisodeWithMetadata; + + expect(firstEpisode.id).not.toBe(secondEpisode.id); + expect(firstEpisode.legacyTrackingId).toBe(624320047); + expect(firstEpisode.legacyTrackingId).toBe( + secondEpisode.legacyTrackingId + ); + }); + + it('scopes VOD-series episode IDs by provider episode identity', () => { + const baseSeason = { + id: 's1', + video_id: 'v1', + name: 'Season 1', + season_number: '1', + isLoading: false, + isExpanded: false, + }; + const firstSeries = mapVodSeriesEpisodes( + [ + { + ...baseSeason, + episodes: [ + { + id: 'provider-episode-1', + series_number: 1, + name: 'Episode 1', + }, + ], + }, + ], + { parentSeriesId: 100 } + ); + const secondSeries = mapVodSeriesEpisodes( + [ + { + ...baseSeason, + episodes: [ + { + id: 'provider-episode-2', + series_number: 1, + name: 'Episode 1', + }, + ], + }, + ], + { parentSeriesId: 100 } + ); + + expect(firstSeries['1'][0].id).not.toBe(secondSeries['1'][0].id); + }); + + it('derives missing VOD-series season numbers from natural season order', () => { + const mapped = mapVodSeriesEpisodes( + [ + { + id: 'season-2', + video_id: 'v1', + name: 'Season 2', + season_number: '', + episodes: [ + { + id: 'episode-2', + series_number: 1, + name: 'Second season pilot', + }, + ], + isLoading: false, + isExpanded: false, + }, + { + id: 'season-1', + video_id: 'v1', + name: 'Season 1', + season_number: '', + episodes: [ + { + id: 'episode-1', + series_number: 1, + name: 'Pilot', + }, + ], + isLoading: false, + isExpanded: false, + }, + ], + { parentSeriesId: 100 } + ); expect(mapped['Season 1'][0].season).toBe(1); expect(mapped['Season 2'][0].season).toBe(2); @@ -132,6 +249,7 @@ describe('stalker-series.adapters', () => { expect(mapped['1']).toHaveLength(2); const firstEpisode = mapped['1'][0] as EpisodeWithMetadata; expect(firstEpisode.custom_sid).toBe('regular-series'); + expect(firstEpisode.id).toBe('91189090'); expect(firstEpisode.originalCmd).toBe('/media/file_100.mpg'); }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts index 23bd5e1fc..17ec5dcec 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts @@ -29,7 +29,13 @@ export interface StalkerSeriesSeasonVm { series: number[]; } +export interface MapVodSeriesEpisodesOptions { + parentSeriesId: string | number; + fallbackPoster?: string; +} + export interface StalkerMappedEpisode extends XtreamSerieEpisode { + legacyTrackingId?: number; originalId?: string; originalCmd?: string; } @@ -44,15 +50,34 @@ function hashString(str: string): number { return Math.abs(hash); } -function generateEpisodeId( - seed: string, +function generateLegacyVodEpisodeId( episodeNum: number, - seasonKey: string, - isVodSeries: boolean + seasonKey: string +): number { + return hashString(`vod_${seasonKey}_${episodeNum}`); +} + +function generateVodEpisodeId(options: { + parentSeriesId: string | number; + providerEpisodeId: string; + seasonKey: string; + episodeNum: number; +}): number { + return hashString( + JSON.stringify([ + 'vod', + String(options.parentSeriesId), + options.providerEpisodeId, + options.seasonKey, + options.episodeNum, + ]) + ); +} + +function generateRegularEpisodeId( + seed: string, + episodeNum: number ): number { - if (isVodSeries) { - return hashString(`vod_${seasonKey}_${episodeNum}`); - } return hashString(`${seed}_ep_${episodeNum}`); } @@ -153,7 +178,7 @@ function createBaseEpisode( export function mapVodSeriesEpisodes( seasons: ReadonlyArray, - fallbackPoster?: string + options: MapVodSeriesEpisodesOptions ): Record { const mapped: Record = {}; @@ -165,12 +190,17 @@ export function mapVodSeriesEpisodes( const episodeNum = toEpisodeNumber(episode.series_number) || toEpisodeNumber(episode.episode_num); - const trackingId = generateEpisodeId( - String(episode.id ?? ''), + const providerEpisodeId = String(episode.id ?? ''); + const legacyTrackingId = generateLegacyVodEpisodeId( episodeNum, - seasonKey, - true + seasonKey ); + const trackingId = generateVodEpisodeId({ + parentSeriesId: options.parentSeriesId, + providerEpisodeId, + seasonKey, + episodeNum, + }); return { ...createBaseEpisode( @@ -181,14 +211,15 @@ export function mapVodSeriesEpisodes( 'vod-series', seasonNum, { - movie_image: episode.cover || fallbackPoster, + movie_image: episode.cover || options.fallbackPoster, plot: episode.description || '', duration: episode.duration ? `${episode.duration} min` : '', } ), - originalId: String(episode.id ?? ''), + legacyTrackingId, + originalId: providerEpisodeId, } as StalkerMappedEpisode; }); }); @@ -205,11 +236,9 @@ export function mapRegularSeriesEpisodes( seasons.forEach((season, index) => { const seasonKey = String(index + 1); mapped[seasonKey] = (season.series ?? []).map((episodeNum) => { - const trackingId = generateEpisodeId( + const trackingId = generateRegularEpisodeId( String(season.cmd ?? ''), - episodeNum, - seasonKey, - false + episodeNum ); return { diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts index c37614014..cda2d7219 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts @@ -5,11 +5,31 @@ import { createStalkerInfo, createStalkerInlineDetailState, createStalkerDetailViewState, + isStalkerSeriesFlag, normalizeStalkerFavoriteItem, + normalizeStalkerSeriesFlag, toggleStalkerVodFavorite, } from './stalker-vod.utils'; describe('stalker-vod.utils regressions', () => { + describe('Stalker series flag contract', () => { + it.each([true, 1, '1'])( + 'accepts %p and normalizes it to the positive marker', + (value) => { + expect(isStalkerSeriesFlag(value)).toBe(true); + expect(normalizeStalkerSeriesFlag(value)).toBe(true); + } + ); + + it.each([false, 0, '0', 'true', null, undefined, {}, []])( + 'rejects unsupported value %p', + (value) => { + expect(isStalkerSeriesFlag(value)).toBe(false); + expect(normalizeStalkerSeriesFlag(value)).toBeUndefined(); + } + ); + }); + it('routes embedded series[] items to series view state', () => { const state = createStalkerDetailViewState( { diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts index b70c28e65..bd0ae96ae 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts @@ -54,10 +54,7 @@ export function isStalkerSeriesFlag(value: unknown): boolean { export function normalizeStalkerSeriesFlag( value: unknown ): StalkerSeriesFlag | undefined { - if (value === true || value === 1 || value === '1') { - return true; - } - return undefined; + return isStalkerSeriesFlag(value) ? true : undefined; } export function isStalkerSeriesItem(item: { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts index 976d7b50d..b7ef4a5da 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts @@ -1,7 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; import { DataService, TmdbEnrichmentService } from '@iptvnator/services'; -import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces'; +import { + PlaylistMeta, + StalkerPortalActions, +} from '@iptvnator/shared/interfaces'; import { StalkerSessionService } from '../../stalker-session.service'; import { withStalkerSelection } from './with-stalker-selection.feature'; import { withStalkerSeries } from './with-stalker-series.feature'; @@ -187,22 +190,41 @@ describe('withStalkerSeries serialSeasonsResource gating', () => { }); }); - it('does not fire a series request for a Ministra VOD-series item', async () => { + it.each([true, 1, '1'] as const)( + 'does not fire a series request for a Ministra VOD-series item with is_series=%p', + async (isSeries) => { + store.setSelectedContentType('vod'); + store.setSelectedItem({ + id: '11', + name: 'VOD series', + is_series: isSeries, + }); + + // The legit vod-series season request (type=vod) may fire; the + // wasted regular-series request (type=series) must not. + await waitForCondition( + () => dataService.sendIpcEvent.mock.calls.length > 0 + ); + await flushResources(); + + expect(seriesRequestCalls(dataService.sendIpcEvent)).toHaveLength( + 0 + ); + } + ); + + it('does not fetch VOD-series seasons for unsupported truthy is_series', async () => { store.setSelectedContentType('vod'); store.setSelectedItem({ id: '11', - name: 'VOD series', - is_series: '1', + name: 'Plain VOD', + is_series: 'true', }); - // The legit vod-series season request (type=vod) may fire; the - // wasted regular-series request (type=series) must not. - await waitForCondition( - () => dataService.sendIpcEvent.mock.calls.length > 0 - ); + await flushResources(); await flushResources(); - expect(seriesRequestCalls(dataService.sendIpcEvent)).toHaveLength(0); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); }); it('does not refetch seasons when a VOD item is selected after a series', async () => { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts index d545fdc4f..79ecf9256 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts @@ -17,6 +17,7 @@ import { } from '../../models'; import { StalkerContentTypes } from '../../stalker-content-types'; import { StalkerSessionService } from '../../stalker-session.service'; +import { isStalkerSeriesFlag } from '../../stalker-vod.utils'; import { StalkerSeriesFeatureStoreContract } from '../stalker-store.contracts'; import { executeStalkerRequest, @@ -155,7 +156,7 @@ export function withStalkerSeries() { !selectedItem || selectedItem.id === undefined || selectedItem.id === null || - !selectedItem.is_series + !isStalkerSeriesFlag(selectedItem.is_series) ) { logger.debug( 'vodSeriesSeasonsResource skipped - conditions not met' diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index 4edc8b0f4..a04627bf4 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -20,6 +20,7 @@ import { import { createPortalFavoritesResource, createRefreshTrigger, + isStalkerSeriesFlag, isSelectedStalkerVodFavorite, StalkerSelectedVodItem, toggleStalkerVodFavorite, @@ -92,8 +93,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy { return Boolean( item && (this.contentType() === 'series' || - item.is_series === true || - String(item.is_series) === '1') + isStalkerSeriesFlag(item.is_series)) ); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts index 49d710289..8bb750b82 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts @@ -18,8 +18,7 @@ describe('StalkerCatalogFacadeService', () => { }; const unsubscribe = jest.fn(); let playbackUpdateHandler: - | ((data: PlaybackPositionData) => void) - | undefined; + ((data: PlaybackPositionData) => void) | undefined; let playbackPositionBridge: { onPlaybackPositionUpdate: jest.Mock< (() => void) | undefined, @@ -49,6 +48,10 @@ describe('StalkerCatalogFacadeService', () => { [string, number, 'vod' | 'episode'] >; }; + let stalkerStoreMock: Record & { + setSearchPhrase: jest.Mock; + setSelectedItem: jest.Mock; + }; beforeEach(() => { playbackUpdateHandler = undefined; @@ -68,38 +71,37 @@ describe('StalkerCatalogFacadeService', () => { } ), }; + stalkerStoreMock = { + selectedContentType: signal<'vod' | 'series' | 'itv'>('vod'), + limit: signal(14), + page: signal(0), + getSelectedCategory: signal(null), + getPaginatedContent: signal([]), + selectedItem: signal(null), + getTotalPages: signal(0), + isPaginatedContentLoading: signal(false), + currentPlaylist: signal(playlist), + getSelectedCategoryName: jest.fn(() => null), + setSelectedCategory: jest.fn(), + clearSelectedItem: jest.fn(), + setSearchPhrase: jest.fn(), + setPage: jest.fn(), + setLimit: jest.fn(), + setSelectedItem: jest.fn(), + createLinkToPlayVod: jest.fn(), + addToFavorites: jest.fn(), + removeFromFavorites: jest.fn(), + fetchMovieFileId: jest.fn(), + fetchLinkToPlay: jest.fn(), + resolveVodPlayback: jest.fn(), + }; TestBed.configureTestingModule({ providers: [ StalkerCatalogFacadeService, { provide: StalkerStore, - useValue: { - selectedContentType: signal<'vod' | 'series' | 'itv'>( - 'vod' - ), - limit: signal(14), - page: signal(0), - getSelectedCategory: signal(null), - getPaginatedContent: signal([]), - selectedItem: signal(null), - getTotalPages: signal(0), - isPaginatedContentLoading: signal(false), - currentPlaylist: signal(playlist), - getSelectedCategoryName: jest.fn(() => null), - setSelectedCategory: jest.fn(), - clearSelectedItem: jest.fn(), - setSearchPhrase: jest.fn(), - setPage: jest.fn(), - setLimit: jest.fn(), - setSelectedItem: jest.fn(), - createLinkToPlayVod: jest.fn(), - addToFavorites: jest.fn(), - removeFromFavorites: jest.fn(), - fetchMovieFileId: jest.fn(), - fetchLinkToPlay: jest.fn(), - resolveVodPlayback: jest.fn(), - }, + useValue: stalkerStoreMock, }, { provide: PORTAL_PLAYBACK_POSITIONS, @@ -115,15 +117,60 @@ describe('StalkerCatalogFacadeService', () => { it('delegates category search query updates to the Stalker store', () => { const service = TestBed.inject(StalkerCatalogFacadeService); - const store = TestBed.inject(StalkerStore) as unknown as { - setSearchPhrase: jest.Mock; - }; service.setSearchQuery('matrix'); - expect(store.setSearchPhrase).toHaveBeenCalledWith('matrix'); + expect(stalkerStoreMock.setSearchPhrase).toHaveBeenCalledWith('matrix'); }); + it.each([true, 1, '1'] as const)( + 'normalizes supported is_series flag %p when selecting an item', + (isSeries) => { + const service = TestBed.inject(StalkerCatalogFacadeService); + + service.selectItem({ id: '42', is_series: isSeries }); + + expect(stalkerStoreMock.setSelectedItem).toHaveBeenCalledWith( + expect.objectContaining({ + id: '42', + is_series: true, + }) + ); + } + ); + + it.each([true, 1, '1'] as const)( + 'returns empty series progress for supported is_series flag %p', + (isSeries) => { + const service = TestBed.inject(StalkerCatalogFacadeService); + + expect( + service.getItemProgress({ id: '42', is_series: isSeries }) + ).toEqual({ hasSeriesProgress: false }); + } + ); + + it.each([false, 0] as const)( + 'keeps non-series flag %p on the ordinary VOD path', + (isSeries) => { + const service = TestBed.inject(StalkerCatalogFacadeService); + const item = { id: '42', is_series: isSeries }; + + service.selectItem(item); + + expect(stalkerStoreMock.setSelectedItem).toHaveBeenCalledWith( + expect.objectContaining({ + id: '42', + is_series: undefined, + }) + ); + expect(service.getItemProgress(item)).toEqual({ + progress: 0, + isWatched: false, + }); + } + ); + it('persists matching external playback updates for the current playlist', async () => { TestBed.inject(StalkerCatalogFacadeService); await Promise.resolve(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index 893374232..4a9188a27 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -9,6 +9,7 @@ import { } from '@angular/core'; import { buildStalkerSelectedVodItem, + isStalkerSeriesFlag, StalkerStore, StalkerVodSource, } from '@iptvnator/portal/stalker/data-access'; @@ -185,8 +186,7 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< selectItem(item: StalkerVodSource): string[] | null { const needsSeriesFetch = - this.contentType() === 'vod' && - (item.is_series === '1' || item.is_series === 1); + this.contentType() === 'vod' && isStalkerSeriesFlag(item.is_series); this.stalkerStore.setSelectedItem( buildStalkerSelectedVodItem(item, needsSeriesFetch) @@ -209,8 +209,7 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< ); const isSeries = this.contentType() === 'series' || - item.is_series === '1' || - item.is_series === 1; + isStalkerSeriesFlag(item.is_series); if (hasSeriesProgress) { return { hasSeriesProgress: true }; diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.spec.ts new file mode 100644 index 000000000..4b5b37a1a --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.spec.ts @@ -0,0 +1,573 @@ +import type { + PlaybackPositionData, + XtreamSerieEpisode, +} from '@iptvnator/shared/interfaces'; +import type { StalkerMappedEpisode } from '@iptvnator/portal/stalker/data-access'; +import { + clearStalkerSeriesPosition, + reconcileStalkerSeriesPositions, + saveStalkerSeriesPosition, + StalkerSeriesPositionPartialSaveError, +} from './stalker-series-position-compatibility'; + +const PLAYLIST_ID = 'playlist-1'; +const SERIES_ID = 100; +const FOREIGN_SERIES_ID = 200; +const SCOPED_TRACKING_ID = 1001; +const LEGACY_TRACKING_ID = 501; + +interface EpisodeOptions { + trackingId?: number; + legacyTrackingId?: number; + seasonNumber?: number; + episodeNumber?: number; +} + +interface RejectedOwnershipCase { + name: string; + position: PlaybackPositionData; + legacyPosition?: PlaybackPositionData; +} + +function createEpisode(options: EpisodeOptions = {}): StalkerMappedEpisode { + const episodeNumber = options.episodeNumber ?? 2; + return { + id: String(options.trackingId ?? SCOPED_TRACKING_ID), + episode_num: episodeNumber, + title: `Episode ${episodeNumber}`, + container_extension: 'mpg', + info: {}, + custom_sid: 'vod-series', + added: '', + season: options.seasonNumber ?? 1, + direct_source: '', + legacyTrackingId: options.legacyTrackingId, + originalId: `provider-${episodeNumber}`, + }; +} + +function createPosition( + overrides: Partial = {} +): PlaybackPositionData { + return { + contentXtreamId: SCOPED_TRACKING_ID, + contentType: 'episode', + seriesXtreamId: SERIES_ID, + seasonNumber: 1, + episodeNumber: 2, + positionSeconds: 45, + durationSeconds: 120, + playlistId: PLAYLIST_ID, + ...overrides, + }; +} + +function reconcile( + episodes: readonly XtreamSerieEpisode[], + seriesPositions: readonly PlaybackPositionData[], + seriesXtreamId = SERIES_ID +) { + return reconcileStalkerSeriesPositions({ + seriesXtreamId, + episodesBySeason: { '1': episodes }, + seriesPositions, + }); +} + +describe('stalker series position compatibility', () => { + it('prefers an exact scoped row while retaining compatible legacy cleanup metadata', () => { + const episode = createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }); + const exactPosition = createPosition({ positionSeconds: 90 }); + const legacyPosition = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + positionSeconds: 30, + }); + + const result = reconcile( + [episode], + [legacyPosition, exactPosition] + ); + + expect(result.positionsByTrackingId.get(SCOPED_TRACKING_ID)).toBe( + exactPosition + ); + expect( + result.legacyPositionByTrackingId.get(SCOPED_TRACKING_ID) + ).toBe(legacyPosition); + expect( + result.positionsByTrackingId.has(LEGACY_TRACKING_ID) + ).toBe(false); + }); + + it('aliases only the current parent legacy row under the scoped ID without mutating it', () => { + const episode = createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }); + const currentParentLegacy = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + seasonNumber: undefined, + episodeNumber: undefined, + }); + const foreignParentLegacy = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + seriesXtreamId: FOREIGN_SERIES_ID, + positionSeconds: 99, + }); + + const result = reconcile( + [episode], + [foreignParentLegacy, currentParentLegacy] + ); + const migrated = result.positionsByTrackingId.get(SCOPED_TRACKING_ID); + + expect(migrated).toEqual({ + ...currentParentLegacy, + contentXtreamId: SCOPED_TRACKING_ID, + seriesXtreamId: SERIES_ID, + seasonNumber: 1, + episodeNumber: 2, + }); + expect(migrated).not.toBe(currentParentLegacy); + expect(currentParentLegacy.contentXtreamId).toBe(LEGACY_TRACKING_ID); + expect( + result.legacyPositionByTrackingId.get(SCOPED_TRACKING_ID) + ).toBe(currentParentLegacy); + + const foreignResult = reconcile( + [episode], + [foreignParentLegacy], + SERIES_ID + ); + expect(foreignResult.positionsByTrackingId.size).toBe(0); + expect(foreignResult.legacyPositionByTrackingId.size).toBe(0); + }); + + it('matches present coordinates numerically and treats nullish coordinates as absent', () => { + const episode = createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }); + const numericStringCoordinates = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + seasonNumber: '1' as unknown as number, + episodeNumber: '2' as unknown as number, + }); + const absentCoordinates = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + seasonNumber: null as unknown as number, + episodeNumber: undefined, + }); + + expect( + reconcile( + [episode], + [numericStringCoordinates] + ).positionsByTrackingId.has(SCOPED_TRACKING_ID) + ).toBe(true); + expect( + reconcile( + [episode], + [absentCoordinates] + ).positionsByTrackingId.has(SCOPED_TRACKING_ID) + ).toBe(true); + }); + + it('rejects a legacy alias when either present coordinate conflicts', () => { + const episode = createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }); + const wrongSeason = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + seasonNumber: 3, + }); + const wrongEpisode = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + episodeNumber: 8, + }); + + for (const position of [wrongSeason, wrongEpisode]) { + const result = reconcile([episode], [position]); + expect(result.positionsByTrackingId.size).toBe(0); + expect(result.legacyPositionByTrackingId.size).toBe(0); + } + }); + + it('never aliases an episode without legacy tracking metadata', () => { + const episode = createEpisode(); + const legacyPosition = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }); + + const result = reconcile([episode], [legacyPosition]); + + expect(result.positionsByTrackingId.size).toBe(0); + expect(result.legacyPositionByTrackingId.size).toBe(0); + }); + + it('awaits a scoped save before clearing the confirmed legacy row', async () => { + const order: string[] = []; + const legacyPosition = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }); + const position = reconcile( + [ + createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }), + ], + [legacyPosition] + ).positionsByTrackingId.get(SCOPED_TRACKING_ID); + expect(position).toBeDefined(); + if (!position) { + throw new Error('Expected a migrated scoped position'); + } + const repository = { + savePlaybackPosition: jest.fn( + async (_playlistId: string, saved: PlaybackPositionData) => { + order.push(`save:${saved.contentXtreamId}`); + } + ), + clearPlaybackPosition: jest.fn( + async ( + _playlistId: string, + contentXtreamId: number + ) => { + order.push(`clear:${contentXtreamId}`); + } + ), + }; + + await expect( + saveStalkerSeriesPosition({ + repository, + playlistId: PLAYLIST_ID, + position, + legacyPosition, + }) + ).resolves.toBe(true); + expect(order).toEqual([ + `save:${SCOPED_TRACKING_ID}`, + `clear:${LEGACY_TRACKING_ID}`, + ]); + }); + + it('does not clear legacy when the scoped save rejects', async () => { + const saveError = new Error('save failed'); + const repository = { + savePlaybackPosition: jest.fn().mockRejectedValue(saveError), + clearPlaybackPosition: jest.fn().mockResolvedValue(undefined), + }; + + await expect( + saveStalkerSeriesPosition({ + repository, + playlistId: PLAYLIST_ID, + position: createPosition(), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }), + }) + ).rejects.toBe(saveError); + expect(repository.clearPlaybackPosition).not.toHaveBeenCalled(); + }); + + it('reports a partial save when legacy cleanup rejects after the scoped write', async () => { + const order: string[] = []; + const clearError = new Error('clear failed'); + const repository = { + savePlaybackPosition: jest.fn( + async (_playlistId: string, position: PlaybackPositionData) => { + order.push(`save:${position.contentXtreamId}`); + } + ), + clearPlaybackPosition: jest.fn( + async (_playlistId: string, contentXtreamId: number) => { + order.push(`clear:${contentXtreamId}`); + throw clearError; + } + ), + }; + + const save = saveStalkerSeriesPosition({ + repository, + playlistId: PLAYLIST_ID, + position: createPosition(), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }), + }); + + const rejection = await save.catch( + (error: unknown) => error + ); + expect(rejection).toBeInstanceOf( + StalkerSeriesPositionPartialSaveError + ); + if ( + !(rejection instanceof + StalkerSeriesPositionPartialSaveError) + ) { + throw new Error('Expected a partial save error'); + } + expect(rejection.cause).toBe(clearError); + expect(rejection.scopedPositionSaved).toBe(true); + expect(order).toEqual([ + `save:${SCOPED_TRACKING_ID}`, + `clear:${LEGACY_TRACKING_ID}`, + ]); + }); + + const rejectedOwnershipCases: RejectedOwnershipCase[] = [ + { + name: 'different parents', + position: createPosition(), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + seriesXtreamId: FOREIGN_SERIES_ID, + }), + }, + { + name: 'missing scoped parent', + position: createPosition({ seriesXtreamId: undefined }), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }), + }, + { + name: 'missing legacy parent', + position: createPosition(), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + seriesXtreamId: undefined, + }), + }, + { + name: 'equal scoped and legacy IDs', + position: createPosition(), + legacyPosition: createPosition(), + }, + { + name: 'non-episode scoped content', + position: createPosition({ contentType: 'vod' }), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }), + }, + { + name: 'non-episode legacy content', + position: createPosition(), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + contentType: 'vod', + }), + }, + { + name: 'conflicting scoped playlist', + position: createPosition({ playlistId: 'playlist-2' }), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }), + }, + { + name: 'conflicting legacy playlist', + position: createPosition(), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + playlistId: 'playlist-2', + }), + }, + { + name: 'missing legacy row', + position: createPosition(), + }, + ]; + + it.each(rejectedOwnershipCases)( + 'does not clean an unowned legacy row for $name', + async ({ position, legacyPosition }) => { + const saveRepository = { + savePlaybackPosition: jest.fn().mockResolvedValue(undefined), + clearPlaybackPosition: jest + .fn() + .mockResolvedValue(undefined), + }; + await expect( + saveStalkerSeriesPosition({ + repository: saveRepository, + playlistId: PLAYLIST_ID, + position, + legacyPosition, + }) + ).resolves.toBe(false); + expect( + saveRepository.savePlaybackPosition + ).toHaveBeenCalledWith(PLAYLIST_ID, position); + expect( + saveRepository.clearPlaybackPosition + ).not.toHaveBeenCalled(); + + const clearRepository = { + clearPlaybackPosition: jest + .fn() + .mockResolvedValue(undefined), + }; + await expect( + clearStalkerSeriesPosition({ + repository: clearRepository, + playlistId: PLAYLIST_ID, + position, + legacyPosition, + }) + ).resolves.toBe(false); + expect( + clearRepository.clearPlaybackPosition + ).toHaveBeenCalledTimes(1); + expect( + clearRepository.clearPlaybackPosition + ).toHaveBeenCalledWith( + PLAYLIST_ID, + position.contentXtreamId, + position.contentType + ); + } + ); + + it('clears confirmed legacy before scoped state and cannot resurrect it', async () => { + const episode = createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }); + const exactPosition = createPosition(); + const legacyPosition = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + positionSeconds: 20, + }); + const rows = new Map([ + [SCOPED_TRACKING_ID, exactPosition], + [LEGACY_TRACKING_ID, legacyPosition], + ]); + const order: string[] = []; + const repository = { + clearPlaybackPosition: jest.fn( + async ( + _playlistId: string, + contentXtreamId: number + ) => { + order.push(`clear:${contentXtreamId}`); + rows.delete(contentXtreamId); + } + ), + }; + + await expect( + clearStalkerSeriesPosition({ + repository, + playlistId: PLAYLIST_ID, + position: exactPosition, + legacyPosition, + }) + ).resolves.toBe(true); + expect(order).toEqual([ + `clear:${LEGACY_TRACKING_ID}`, + `clear:${SCOPED_TRACKING_ID}`, + ]); + + const refreshed = reconcile([episode], [...rows.values()]); + expect(refreshed.positionsByTrackingId.size).toBe(0); + expect(refreshed.legacyPositionByTrackingId.size).toBe(0); + }); + + it('leaves exact scoped progress when legacy cleanup rejects', async () => { + const episode = createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }); + const exactPosition = createPosition(); + const legacyPosition = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }); + const rows = new Map([ + [SCOPED_TRACKING_ID, exactPosition], + [LEGACY_TRACKING_ID, legacyPosition], + ]); + const clearError = new Error('legacy clear failed'); + const repository = { + clearPlaybackPosition: jest.fn( + async ( + _playlistId: string, + contentXtreamId: number + ) => { + if (contentXtreamId === LEGACY_TRACKING_ID) { + throw clearError; + } + rows.delete(contentXtreamId); + } + ), + }; + + await expect( + clearStalkerSeriesPosition({ + repository, + playlistId: PLAYLIST_ID, + position: exactPosition, + legacyPosition, + }) + ).rejects.toBe(clearError); + expect(rows.get(SCOPED_TRACKING_ID)).toBe(exactPosition); + expect( + reconcile([episode], [...rows.values()]) + .positionsByTrackingId + .get(SCOPED_TRACKING_ID) + ).toBe(exactPosition); + }); + + it('leaves exact scoped progress when scoped clear rejects after legacy cleanup', async () => { + const episode = createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }); + const exactPosition = createPosition(); + const legacyPosition = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }); + const rows = new Map([ + [SCOPED_TRACKING_ID, exactPosition], + [LEGACY_TRACKING_ID, legacyPosition], + ]); + const order: number[] = []; + const clearError = new Error('scoped clear failed'); + const repository = { + clearPlaybackPosition: jest.fn( + async ( + _playlistId: string, + contentXtreamId: number + ) => { + order.push(contentXtreamId); + if (contentXtreamId === SCOPED_TRACKING_ID) { + throw clearError; + } + rows.delete(contentXtreamId); + } + ), + }; + + await expect( + clearStalkerSeriesPosition({ + repository, + playlistId: PLAYLIST_ID, + position: exactPosition, + legacyPosition, + }) + ).rejects.toBe(clearError); + expect(order).toEqual([ + LEGACY_TRACKING_ID, + SCOPED_TRACKING_ID, + ]); + expect(rows.has(LEGACY_TRACKING_ID)).toBe(false); + expect(rows.get(SCOPED_TRACKING_ID)).toBe(exactPosition); + + const refreshed = reconcile([episode], [...rows.values()]); + expect( + refreshed.positionsByTrackingId.get(SCOPED_TRACKING_ID) + ).toBe(exactPosition); + expect(refreshed.legacyPositionByTrackingId.size).toBe(0); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.ts new file mode 100644 index 000000000..f796c31da --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.ts @@ -0,0 +1,203 @@ +import type { StalkerMappedEpisode } from '@iptvnator/portal/stalker/data-access'; +import type { PortalPlaybackPositions } from '@iptvnator/portal/shared/util'; +import type { + PlaybackPositionData, + XtreamSerieEpisode, +} from '@iptvnator/shared/interfaces'; + +export interface ReconciledStalkerSeriesPositions { + positionsByTrackingId: Map; + legacyPositionByTrackingId: Map; +} + +export class StalkerSeriesPositionPartialSaveError extends Error { + readonly cause: unknown; + readonly scopedPositionSaved = true as const; + + constructor(cause: unknown) { + super( + 'Scoped Stalker series position was saved, but legacy cleanup failed' + ); + this.name = 'StalkerSeriesPositionPartialSaveError'; + this.cause = cause; + } +} + +function matchesMappedCoordinate( + value: number | undefined, + mappedValue: number +): boolean { + return value == null || Number(value) === mappedValue; +} + +function isCompatibleLegacyPosition( + position: PlaybackPositionData, + episode: XtreamSerieEpisode +): boolean { + return ( + matchesMappedCoordinate(position.seasonNumber, Number(episode.season)) && + matchesMappedCoordinate( + position.episodeNumber, + Number(episode.episode_num) + ) + ); +} + +export function reconcileStalkerSeriesPositions(options: { + seriesXtreamId: number; + episodesBySeason: Readonly< + Record + >; + seriesPositions: readonly PlaybackPositionData[]; +}): ReconciledStalkerSeriesPositions { + const positionsByTrackingId = new Map(); + const legacyPositionByTrackingId = new Map< + number, + PlaybackPositionData + >(); + const indexedPositions = new Map(); + + for (const position of options.seriesPositions) { + if ( + position.contentType === 'episode' && + position.seriesXtreamId === options.seriesXtreamId + ) { + indexedPositions.set(position.contentXtreamId, position); + } + } + + for (const episodes of Object.values(options.episodesBySeason)) { + for (const episode of episodes) { + const trackingId = Number(episode.id); + if (!Number.isFinite(trackingId)) { + continue; + } + + const exactPosition = indexedPositions.get(trackingId); + if (exactPosition) { + positionsByTrackingId.set(trackingId, exactPosition); + } + + const legacyTrackingId = (episode as StalkerMappedEpisode) + .legacyTrackingId; + if ( + legacyTrackingId == null || + legacyTrackingId === trackingId + ) { + continue; + } + + const legacyPosition = indexedPositions.get(legacyTrackingId); + if ( + !legacyPosition || + !isCompatibleLegacyPosition(legacyPosition, episode) + ) { + continue; + } + + legacyPositionByTrackingId.set(trackingId, legacyPosition); + if (!exactPosition) { + positionsByTrackingId.set(trackingId, { + ...legacyPosition, + contentXtreamId: trackingId, + seriesXtreamId: options.seriesXtreamId, + seasonNumber: Number(episode.season), + episodeNumber: Number(episode.episode_num), + }); + } + } + } + + return { + positionsByTrackingId, + legacyPositionByTrackingId, + }; +} + +function ownsLegacyPosition( + playlistId: string, + position: PlaybackPositionData, + legacyPosition: PlaybackPositionData | undefined +): legacyPosition is PlaybackPositionData { + return ( + position.contentType === 'episode' && + legacyPosition?.contentType === 'episode' && + position.contentXtreamId !== legacyPosition.contentXtreamId && + position.seriesXtreamId != null && + legacyPosition.seriesXtreamId != null && + position.seriesXtreamId === legacyPosition.seriesXtreamId && + (!position.playlistId || position.playlistId === playlistId) && + (!legacyPosition.playlistId || + legacyPosition.playlistId === playlistId) + ); +} + +export async function saveStalkerSeriesPosition(options: { + repository: Pick< + PortalPlaybackPositions, + 'savePlaybackPosition' | 'clearPlaybackPosition' + >; + playlistId: string; + position: PlaybackPositionData; + legacyPosition?: PlaybackPositionData; +}): Promise { + await options.repository.savePlaybackPosition( + options.playlistId, + options.position + ); + + if ( + !ownsLegacyPosition( + options.playlistId, + options.position, + options.legacyPosition + ) + ) { + return false; + } + + try { + await options.repository.clearPlaybackPosition( + options.playlistId, + options.legacyPosition.contentXtreamId, + options.legacyPosition.contentType + ); + } catch (cause) { + throw new StalkerSeriesPositionPartialSaveError(cause); + } + return true; +} + +export async function clearStalkerSeriesPosition(options: { + repository: Pick; + playlistId: string; + position: PlaybackPositionData; + legacyPosition?: PlaybackPositionData; +}): Promise { + if ( + ownsLegacyPosition( + options.playlistId, + options.position, + options.legacyPosition + ) + ) { + await options.repository.clearPlaybackPosition( + options.playlistId, + options.legacyPosition.contentXtreamId, + options.legacyPosition.contentType + ); + await options.repository.clearPlaybackPosition( + options.playlistId, + options.position.contentXtreamId, + options.position.contentType + ); + return true; + } + + await options.repository.clearPlaybackPosition( + options.playlistId, + options.position.contentXtreamId, + options.position.contentType + ); + return false; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 5f7615f50..07ae93308 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -201,7 +201,7 @@ (episodeClicked)="onEpisodeClicked($event)" (episodeDownloadRequested)="downloadEpisode($event)" (playbackToggleRequested)=" - handlePlaybackToggleRequested($event) + handlePlaybackToggleRequestedFromUi($event) " #seasonContainer /> diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 88d6d48a1..f21b184a6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -76,6 +76,19 @@ import { getStalkerSeriesQuickStartButton, type StalkerQuickStartButton, } from './stalker-series-quick-start'; +import { + clearStalkerSeriesPosition, + reconcileStalkerSeriesPositions, + saveStalkerSeriesPosition, + StalkerSeriesPositionPartialSaveError, +} from './stalker-series-position-compatibility'; + +interface SeriesPositionContext { + readonly generation: number; + readonly playlistId: string; + readonly seriesXtreamId: number; + readonly mutationKey: string; +} /** * Component for displaying series/episodes for Stalker portal content. @@ -105,6 +118,26 @@ import { export class StalkerSeriesViewComponent implements OnDestroy { readonly stalkerStore = inject(StalkerStore); private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); + private readonly migrationPlaybackPositions = { + savePlaybackPosition: ( + playlistId: string, + data: PlaybackPositionData + ) => + this.playbackPositions.savePlaybackPositionOrThrow( + playlistId, + data + ), + clearPlaybackPosition: ( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ) => + this.playbackPositions.clearPlaybackPositionOrThrow( + playlistId, + contentXtreamId, + contentType + ), + }; private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly router = inject(Router); private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); @@ -120,6 +153,24 @@ export class StalkerSeriesViewComponent implements OnDestroy { readonly episodePlaybackPositions = signal< Map >(new Map()); + private readonly rawSeriesPositions = signal< + readonly PlaybackPositionData[] + >([]); + private readonly legacyPositionByTrackingId = signal< + Map + >(new Map()); + private activeSeriesPositionContext: SeriesPositionContext | null = null; + private seriesPositionContextGeneration = 0; + private readonly seriesPositionMutationQueues = new Map< + string, + Promise + >(); + private readonly pendingSeriesPositionLoads = new Map< + SeriesPositionContext, + Set + >(); + private readonly seriesPositionReloadKeys = new Set(); + private seriesPositionsLoadGeneration = 0; private lastSaveTime = 0; private unsubscribePositionUpdates: (() => void) | null = null; readonly openingEpisodeId = signal(null); @@ -276,24 +327,68 @@ export class StalkerSeriesViewComponent implements OnDestroy { effect(() => { const item = this.displayItem(); const playlist = this.stalkerStore.currentPlaylist(); - if (item && playlist?._id) { - const normalizedSeriesId = this.toSeriesId(item.id); + const normalizedSeriesId = this.toSeriesId(item?.id ?? 0); + if (item && playlist?._id && normalizedSeriesId > 0) { this.logger.debug('Loading positions for series', { id: item.id, seriesId: normalizedSeriesId, isSeries: item.is_series, }); - if (!isNaN(normalizedSeriesId)) { - void this.loadSeriesPositions( - playlist._id, - normalizedSeriesId - ); - } - } else { + this.rawSeriesPositions.set([]); this.episodePlaybackPositions.set(new Map()); + this.legacyPositionByTrackingId.set(new Map()); + const context = this.activateSeriesPositionContext( + playlist._id, + normalizedSeriesId + ); + void this.loadSeriesPositions(context); + } else { + this.activeSeriesPositionContext = null; + this.seriesPositionContextGeneration++; + this.seriesPositionsLoadGeneration++; } }); + effect(() => { + const item = this.displayItem(); + const playlistId = this.stalkerStore.currentPlaylist()?._id; + const seriesXtreamId = this.toSeriesId(item?.id ?? 0); + const rawSeriesPositions = this.rawSeriesPositions(); + const episodesBySeason = this.mappedSeasons(); + + if (!item || !playlistId || seriesXtreamId <= 0) { + if (rawSeriesPositions.length > 0) { + this.rawSeriesPositions.set([]); + } + if (this.episodePlaybackPositions().size > 0) { + this.episodePlaybackPositions.set(new Map()); + } + if (this.legacyPositionByTrackingId().size > 0) { + this.legacyPositionByTrackingId.set(new Map()); + } + return; + } + + const reconciled = reconcileStalkerSeriesPositions({ + seriesXtreamId, + episodesBySeason, + seriesPositions: rawSeriesPositions, + }); + if ( + rawSeriesPositions.length === 0 && + reconciled.positionsByTrackingId.size === 0 && + untracked(() => this.episodePlaybackPositions().size) > 0 + ) { + return; + } + this.episodePlaybackPositions.set( + reconciled.positionsByTrackingId + ); + this.legacyPositionByTrackingId.set( + reconciled.legacyPositionByTrackingId + ); + }); + effect(() => { const session = this.externalPlayback.activeSession(); const item = this.displayItem(); @@ -337,6 +432,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { const seriesId = item ? this.toSeriesId(item.id) : 0; if ( + !playlistId || data.contentType !== 'episode' || data.playlistId !== playlistId || data.seriesXtreamId !== seriesId @@ -344,7 +440,18 @@ export class StalkerSeriesViewComponent implements OnDestroy { return; } - this.updateEpisodePlaybackPosition(data); + // The facade/runtime already saved this row. Repeat the + // idempotent upsert because only this view owns the + // scoped-to-legacy cleanup mapping. + void this.persistSeriesPosition( + playlistId, + data + ).catch((error: unknown) => { + this.logger.error( + 'Failed to persist runtime series position', + error + ); + }); } ) ?? null; } @@ -388,22 +495,20 @@ export class StalkerSeriesViewComponent implements OnDestroy { */ readonly mappedSeasons = computed>( () => { + const displayItem = this.displayItem(); const base = this.isVodSeries() - ? mapVodSeriesEpisodes( - this.vodSeriesSeasons(), - this.displayItem()?.info?.movie_image - ) + ? mapVodSeriesEpisodes(this.vodSeriesSeasons(), { + parentSeriesId: this.toSeriesId(displayItem?.id ?? 0), + fallbackPoster: displayItem?.info?.movie_image, + }) : mapRegularSeriesEpisodes( this.regularSeasons(), - this.displayItem()?.info?.movie_image + displayItem?.info?.movie_image ); // Overlay lazily fetched TMDB episode data (real names, // overviews, stills) — a no-op while nothing is fetched - return this.tmdbSeasons.overlay( - base, - this.displayItem()?.info?.tmdb_id - ); + return this.tmdbSeasons.overlay(base, displayItem?.info?.tmdb_id); } ); @@ -750,11 +855,15 @@ export class StalkerSeriesViewComponent implements OnDestroy { positionSeconds: Math.floor(event.currentTime), durationSeconds: Math.floor(event.duration), }; - void this.playbackPositions.savePlaybackPosition( + void this.persistSeriesPosition( playback.contentInfo.playlistId, position - ); - this.updateEpisodePlaybackPosition(position); + ).catch((error: unknown) => { + this.logger.error( + 'Failed to persist inline series position', + error + ); + }); } showCopyNotification(): void { @@ -894,20 +1003,30 @@ export class StalkerSeriesViewComponent implements OnDestroy { } if (request.nextPosition) { - await this.playbackPositions.savePlaybackPosition( + await this.persistSeriesPosition( playlistId, request.nextPosition ); - this.updateEpisodePlaybackPosition(request.nextPosition); return; } - await this.playbackPositions.clearPlaybackPosition( + await this.clearSeriesPosition( playlistId, - request.contentXtreamId, - 'episode' + request.contentXtreamId + ); + } + + handlePlaybackToggleRequestedFromUi( + request: SeasonContainerPlaybackToggleRequest + ): void { + void this.handlePlaybackToggleRequested(request).catch( + (error: unknown) => { + this.logger.error( + 'Failed to update series playback position', + error + ); + } ); - this.removeEpisodePlaybackPosition(request.contentXtreamId); } async downloadEpisode(episode: XtreamSerieEpisode): Promise { @@ -975,19 +1094,320 @@ export class StalkerSeriesViewComponent implements OnDestroy { } private async loadSeriesPositions( + context: SeriesPositionContext + ): Promise { + const generation = ++this.seriesPositionsLoadGeneration; + this.trackPendingSeriesPositionLoad(context, generation); + try { + await this.waitForSeriesPositionMutations( + context.mutationKey + ); + + if ( + generation !== this.seriesPositionsLoadGeneration || + !this.isSeriesPositionContextActive(context) + ) { + return; + } + + const positions = + await this.playbackPositions.getSeriesPlaybackPositions( + context.playlistId, + context.seriesXtreamId + ); + + if ( + generation !== this.seriesPositionsLoadGeneration || + !this.isSeriesPositionContextActive(context) + ) { + return; + } + + this.rawSeriesPositions.set(positions); + } finally { + this.untrackPendingSeriesPositionLoad(context, generation); + } + } + + private activateSeriesPositionContext( playlistId: string, seriesXtreamId: number + ): SeriesPositionContext { + const context: SeriesPositionContext = { + generation: ++this.seriesPositionContextGeneration, + playlistId, + seriesXtreamId, + mutationKey: JSON.stringify([playlistId, seriesXtreamId]), + }; + this.activeSeriesPositionContext = context; + return context; + } + + private isSeriesPositionContextActive( + context: SeriesPositionContext + ): boolean { + const activeContext = this.activeSeriesPositionContext; + return ( + activeContext === context && + activeContext.generation === context.generation && + this.stalkerStore.currentPlaylist()?._id === + context.playlistId && + this.toSeriesId(this.displayItem()?.id ?? 0) === + context.seriesXtreamId + ); + } + + private waitForSeriesPositionMutations( + mutationKey: string ): Promise { - const positions = - await this.playbackPositions.getSeriesPlaybackPositions( - playlistId, - seriesXtreamId - ); - const positionsMap = new Map(); - positions.forEach((position) => { - positionsMap.set(position.contentXtreamId, position); + return ( + this.seriesPositionMutationQueues.get(mutationKey) ?? + Promise.resolve() + ); + } + + private trackPendingSeriesPositionLoad( + context: SeriesPositionContext, + generation: number + ): void { + const generations = + this.pendingSeriesPositionLoads.get(context) ?? + new Set(); + generations.add(generation); + this.pendingSeriesPositionLoads.set(context, generations); + } + + private untrackPendingSeriesPositionLoad( + context: SeriesPositionContext, + generation: number + ): void { + const generations = + this.pendingSeriesPositionLoads.get(context); + generations?.delete(generation); + if (generations?.size === 0) { + this.pendingSeriesPositionLoads.delete(context); + } + } + + private hasCurrentPendingSeriesPositionLoad( + context: SeriesPositionContext + ): boolean { + return Boolean( + this.pendingSeriesPositionLoads + .get(context) + ?.has(this.seriesPositionsLoadGeneration) + ); + } + + private enqueueSeriesPositionMutation( + context: SeriesPositionContext, + operation: () => Promise + ): Promise { + if (this.hasCurrentPendingSeriesPositionLoad(context)) { + this.seriesPositionReloadKeys.add(context.mutationKey); + } + this.seriesPositionsLoadGeneration++; + const previous = this.waitForSeriesPositionMutations( + context.mutationKey + ); + const result = previous.then(operation); + const barrier = result.then( + () => undefined, + () => undefined + ); + this.seriesPositionMutationQueues.set( + context.mutationKey, + barrier + ); + void barrier.then(() => { + if ( + this.seriesPositionMutationQueues.get( + context.mutationKey + ) === barrier + ) { + this.seriesPositionMutationQueues.delete( + context.mutationKey + ); + this.reloadSeriesPositionsAfterMutations( + context.mutationKey + ); + } }); - this.episodePlaybackPositions.set(positionsMap); + return result; + } + + private reloadSeriesPositionsAfterMutations( + mutationKey: string + ): void { + if (!this.seriesPositionReloadKeys.delete(mutationKey)) { + return; + } + const context = this.activeSeriesPositionContext; + if ( + !context || + context.mutationKey !== mutationKey || + !this.isSeriesPositionContextActive(context) || + this.hasCurrentPendingSeriesPositionLoad(context) + ) { + return; + } + void this.loadSeriesPositions(context); + } + + private getSeriesPositionMutationContext( + playlistId: string, + seriesXtreamId?: number | null + ): SeriesPositionContext | null { + const context = this.activeSeriesPositionContext; + if ( + !context || + context.playlistId !== playlistId || + (seriesXtreamId != null && + context.seriesXtreamId !== seriesXtreamId) + ) { + return null; + } + return context; + } + + private persistSeriesPosition( + playlistId: string, + position: PlaybackPositionData + ): Promise { + const context = this.getSeriesPositionMutationContext( + playlistId, + position.seriesXtreamId + ); + if (!context) { + return Promise.resolve(); + } + const legacyPosition = + this.legacyPositionByTrackingId().get( + position.contentXtreamId + ); + return this.enqueueSeriesPositionMutation(context, async () => { + let clearedLegacy: boolean; + try { + clearedLegacy = await saveStalkerSeriesPosition({ + repository: this.migrationPlaybackPositions, + playlistId, + position, + legacyPosition, + }); + } catch (error) { + if ( + error instanceof + StalkerSeriesPositionPartialSaveError && + this.isSeriesPositionContextActive(context) + ) { + this.publishSavedSeriesPosition( + position, + legacyPosition, + false + ); + } + throw error; + } + if (!this.isSeriesPositionContextActive(context)) { + return; + } + this.publishSavedSeriesPosition( + position, + legacyPosition, + clearedLegacy + ); + }); + } + + private publishSavedSeriesPosition( + position: PlaybackPositionData, + legacyPosition: PlaybackPositionData | undefined, + clearedLegacy: boolean + ): void { + const removedTrackingIds = new Set([ + position.contentXtreamId, + ]); + if (clearedLegacy && legacyPosition) { + removedTrackingIds.add(legacyPosition.contentXtreamId); + const legacyPositions = new Map( + this.legacyPositionByTrackingId() + ); + legacyPositions.delete(position.contentXtreamId); + this.legacyPositionByTrackingId.set(legacyPositions); + } + + this.rawSeriesPositions.set([ + ...this.rawSeriesPositions().filter( + (candidate) => + !removedTrackingIds.has( + candidate.contentXtreamId + ) + ), + position, + ]); + this.updateEpisodePlaybackPosition(position); + } + + private clearSeriesPosition( + playlistId: string, + contentXtreamId: number + ): Promise { + const context = this.getSeriesPositionMutationContext(playlistId); + if (!context) { + return Promise.resolve(); + } + const position = + this.episodePlaybackPositions().get(contentXtreamId) ?? { + contentXtreamId, + contentType: 'episode', + positionSeconds: 0, + playlistId, + seriesXtreamId: context.seriesXtreamId, + }; + const legacyPosition = + this.legacyPositionByTrackingId().get(contentXtreamId); + return this.enqueueSeriesPositionMutation(context, async () => { + const clearedLegacy = await clearStalkerSeriesPosition({ + repository: this.migrationPlaybackPositions, + playlistId, + position, + legacyPosition, + }); + if (!this.isSeriesPositionContextActive(context)) { + return; + } + this.publishClearedSeriesPosition( + contentXtreamId, + legacyPosition, + clearedLegacy + ); + }); + } + + private publishClearedSeriesPosition( + contentXtreamId: number, + legacyPosition: PlaybackPositionData | undefined, + clearedLegacy: boolean + ): void { + const removedTrackingIds = new Set([contentXtreamId]); + if (clearedLegacy && legacyPosition) { + removedTrackingIds.add(legacyPosition.contentXtreamId); + const legacyPositions = new Map( + this.legacyPositionByTrackingId() + ); + legacyPositions.delete(contentXtreamId); + this.legacyPositionByTrackingId.set(legacyPositions); + } + + this.rawSeriesPositions.set( + this.rawSeriesPositions().filter( + (candidate) => + !removedTrackingIds.has( + candidate.contentXtreamId + ) + ) + ); + this.removeEpisodePlaybackPosition(contentXtreamId); } private async loadAndPlayVodSeriesSeason( diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts new file mode 100644 index 000000000..98b04d49e --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts @@ -0,0 +1,1199 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { + StalkerStore, + type StalkerVodSource, + type VodSeriesSeasonVm, +} from '@iptvnator/portal/stalker/data-access'; +import type { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { + CrossPortalSimilarService, + DownloadsService, + PlaybackPositionRuntimeBridgeService, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import { EMPTY, of } from 'rxjs'; +import { StalkerSeriesViewComponent } from './stalker-series-view.component'; +const PLAYLIST_ID = 'playlist-1'; +const SECOND_PLAYLIST_ID = 'playlist-2'; +const SERIES_A_ID = 100; +const SERIES_B_ID = 200; +const SCOPED_A_ID = 604391373; +const LEGACY_ID = 624320047; +const REGULAR_ID = 91189090; +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} +function createVodItem(seriesId: number): StalkerVodSource { + return { + id: String(seriesId), + is_series: '1', + info: { + name: `Series ${seriesId}`, + description: 'Lazy series', + movie_image: 'poster.jpg', + }, + }; +} +function createRegularItem(seriesId: number): StalkerVodSource { + return { + id: String(seriesId), + cmd: `/media/file_${seriesId}.mpg`, + info: { + name: `Regular Series ${seriesId}`, + description: 'Regular series', + movie_image: 'poster.jpg', + }, + }; +} +function createSeason( + seriesId: number, + episodes: VodSeriesSeasonVm['episodes'] = [] +): VodSeriesSeasonVm { + return { + id: 'season-1', + video_id: String(seriesId), + name: 'Season 1', + season_number: '1', + episodes, + isLoading: false, + isExpanded: false, + }; +} +function createProviderEpisode( + id = 'provider-episode-1', + episodeNumber = 1 +) { + return { + id, + series_number: episodeNumber, + name: episodeNumber === 1 ? 'Pilot' : `Episode ${episodeNumber}`, + }; +} +function createPosition( + overrides: Partial = {} +): PlaybackPositionData { + return { + contentXtreamId: SCOPED_A_ID, + contentType: 'episode', + seriesXtreamId: SERIES_A_ID, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 40, + durationSeconds: 100, + playlistId: PLAYLIST_ID, + ...overrides, + }; +} +describe('StalkerSeriesViewComponent position compatibility', () => { + let fixture: ComponentFixture; + let repositoryRows: PlaybackPositionData[]; + let repositoryOrder: string[]; + let runtimePositionListener: + | ((position: PlaybackPositionData) => void) + | undefined; + const selectedContentType = signal<'series' | 'vod'>('vod'); + const selectedItem = signal( + createVodItem(SERIES_A_ID) + ); + const currentPlaylist = signal<{ _id: string } | null>({ + _id: PLAYLIST_ID, + }); + const serialSeasonsResource = signal([]); + const vodSeriesSeasonsResource = signal([]); + const fetchVodSeriesEpisodes = jest.fn(); + const getSeriesPlaybackPositions = jest.fn(); + const savePlaybackPosition = jest.fn(); + const clearPlaybackPosition = jest.fn(); + const savePlaybackPositionOrThrow = jest.fn(); + const clearPlaybackPositionOrThrow = jest.fn(); + const onPlaybackPositionUpdate = jest.fn(); + async function settle(): Promise { + for (let pass = 0; pass < 4; pass++) { + fixture.detectChanges(); + await Promise.resolve(); + } + } + async function startWithLoadedEpisode( + seriesId = SERIES_A_ID + ): Promise { + vodSeriesSeasonsResource.set([ + { + id: 'season-1', + video_id: String(seriesId), + name: 'Season 1', + season_number: '1', + }, + ]); + await settle(); + fixture.componentInstance.vodSeriesSeasons.set([ + createSeason(seriesId, [createProviderEpisode()]), + ]); + await settle(); + return Number(fixture.componentInstance.mappedSeasons()['1'][0].id); + } + async function startPendingTwoEpisodeLoad(): Promise<{ + firstPosition: PlaybackPositionData; + pendingLoad: Deferred; + secondPosition: PlaybackPositionData; + }> { + await startWithLoadedEpisode(); + fixture.componentInstance.vodSeriesSeasons.set([ + createSeason(SERIES_A_ID, [ + createProviderEpisode(), + createProviderEpisode('provider-episode-2', 2), + ]), + ]); + await settle(); + const [firstEpisode, secondEpisode] = + fixture.componentInstance.mappedSeasons()['1']; + const firstPosition = createPosition({ + contentXtreamId: Number(firstEpisode.id), + positionSeconds: 15, + }); + const secondPosition = createPosition({ + contentXtreamId: Number(secondEpisode.id), + episodeNumber: 2, + positionSeconds: 25, + }); + repositoryRows = [firstPosition, secondPosition]; + const pendingLoad = createDeferred(); + getSeriesPlaybackPositions.mockImplementationOnce( + () => pendingLoad.promise + ); + selectedItem.set(createVodItem(SERIES_A_ID)); + await settle(); + return { firstPosition, pendingLoad, secondPosition }; + } + beforeEach(async () => { + repositoryRows = []; + repositoryOrder = []; + runtimePositionListener = undefined; + selectedContentType.set('vod'); + selectedItem.set(createVodItem(SERIES_A_ID)); + currentPlaylist.set({ _id: PLAYLIST_ID }); + serialSeasonsResource.set([]); + vodSeriesSeasonsResource.set([]); + + fetchVodSeriesEpisodes.mockReset(); + fetchVodSeriesEpisodes.mockResolvedValue([createProviderEpisode()]); + getSeriesPlaybackPositions.mockReset(); + getSeriesPlaybackPositions.mockImplementation( + async ( + _playlistId: string, + seriesXtreamId: number + ): Promise => + repositoryRows.filter( + (position) => position.seriesXtreamId === seriesXtreamId + ) + ); + savePlaybackPosition.mockReset(); + savePlaybackPosition.mockImplementation( + async ( + _playlistId: string, + position: PlaybackPositionData + ): Promise => { + repositoryOrder.push(`save:${position.contentXtreamId}`); + repositoryRows = repositoryRows.filter( + (row) => row.contentXtreamId !== position.contentXtreamId + ); + repositoryRows.push(position); + } + ); + clearPlaybackPosition.mockReset(); + clearPlaybackPosition.mockImplementation( + async ( + _playlistId: string, + contentXtreamId: number + ): Promise => { + repositoryOrder.push(`clear:${contentXtreamId}`); + repositoryRows = repositoryRows.filter( + (row) => row.contentXtreamId !== contentXtreamId + ); + } + ); + savePlaybackPositionOrThrow + .mockReset() + .mockImplementation(savePlaybackPosition); + clearPlaybackPositionOrThrow + .mockReset() + .mockImplementation(clearPlaybackPosition); + onPlaybackPositionUpdate.mockReset(); + onPlaybackPositionUpdate.mockImplementation( + ( + listener: (position: PlaybackPositionData) => void + ) => { + runtimePositionListener = listener; + return jest.fn(); + } + ); + + await TestBed.configureTestingModule({ + imports: [StalkerSeriesViewComponent], + providers: [ + { + provide: StalkerStore, + useValue: { + selectedItem, + selectedContentType, + currentPlaylist, + getSerialSeasonsResource: () => + serialSeasonsResource(), + getVodSeriesSeasonsResource: () => + vodSeriesSeasonsResource(), + isVodSeriesSeasonsLoading: signal(false), + isSerialSeasonsLoading: signal(false), + fetchVodSeriesEpisodes, + resolveVodPlayback: jest.fn(), + fetchLinkToPlay: jest.fn(), + clearSelectedItem: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getSeriesPlaybackPositions, + savePlaybackPosition, + clearPlaybackPosition, + savePlaybackPositionOrThrow, + clearPlaybackPositionOrThrow, + }, + }, + { + provide: PlaybackPositionRuntimeBridgeService, + useValue: { + onPlaybackPositionUpdate, + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { + activeSession: signal(null), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: jest.fn().mockReturnValue(true), + openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), + }, + }, + { + provide: CrossPortalSimilarService, + useValue: { + isAvailable: false, + matchRecommendations: jest.fn(), + buildLink: jest.fn(), + }, + }, + { + provide: Router, + useValue: { + navigate: jest.fn(), + navigateByUrl: jest.fn(), + }, + }, + { + provide: DownloadsService, + useValue: { + startDownload: jest.fn(), + }, + }, + { + provide: TmdbEnrichmentService, + useValue: { + isEnabled: () => false, + getSeason: jest.fn(), + getSeasonEpisodes: jest.fn(), + }, + }, + { + provide: MatSnackBar, + useValue: { + open: jest.fn(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: EMPTY, + onTranslationChange: EMPTY, + onDefaultLangChange: EMPTY, + }, + }, + ], + }) + .overrideComponent(StalkerSeriesViewComponent, { + set: { + template: '', + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(StalkerSeriesViewComponent); + }); + + afterEach(() => { + fixture.destroy(); + jest.restoreAllMocks(); + }); + + it('reconciles positions that arrive before lazy episodes', async () => { + repositoryRows = [ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 33, + }), + ]; + vodSeriesSeasonsResource.set([ + { + id: 'season-1', + video_id: String(SERIES_A_ID), + name: 'Season 1', + season_number: '1', + }, + ]); + + await settle(); + + expect( + fixture.componentInstance.episodePlaybackPositions().size + ).toBe(0); + const season = fixture.componentInstance.vodSeriesSeasons()[0]; + await fixture.componentInstance.loadEpisodesForSeason(season); + await settle(); + + expect( + Number( + fixture.componentInstance.mappedSeasons()['1'][0].id + ) + ).toBe(SCOPED_A_ID); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toEqual( + expect.objectContaining({ + contentXtreamId: SCOPED_A_ID, + positionSeconds: 33, + seriesXtreamId: SERIES_A_ID, + seasonNumber: 1, + episodeNumber: 1, + }) + ); + }); + + it('keeps exact progress while retaining legacy cleanup metadata for watched saves', async () => { + const exactPosition = createPosition({ positionSeconds: 88 }); + repositoryRows = [ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 22, + }), + exactPosition, + ]; + await startWithLoadedEpisode(); + + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(exactPosition); + + const watchedPosition = createPosition({ + positionSeconds: 100, + durationSeconds: 100, + }); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: watchedPosition, + }); + await settle(); + + expect(repositoryOrder).toEqual([ + `save:${SCOPED_A_ID}`, + `clear:${LEGACY_ID}`, + ]); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(watchedPosition); + + const seasons = fixture.componentInstance.vodSeriesSeasons(); + fixture.componentInstance.vodSeriesSeasons.set( + seasons.map((season) => ({ + ...season, + episodes: [...season.episodes], + })) + ); + await settle(); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: createPosition({ positionSeconds: 99 }), + }); + expect( + repositoryOrder.filter( + (entry) => entry === `clear:${LEGACY_ID}` + ) + ).toHaveLength(1); + }); + + it('publishes a partial save and retries the same legacy cleanup', async () => { + repositoryRows = [ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 22, + }), + ]; + await startWithLoadedEpisode(); + const clearError = new Error('clear failed'); + clearPlaybackPosition.mockRejectedValueOnce(clearError); + const newerPosition = createPosition({ positionSeconds: 70 }); + + await expect( + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: newerPosition, + }) + ).rejects.toEqual( + expect.objectContaining({ + cause: clearError, + scopedPositionSaved: true, + }) + ); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(newerPosition); + + const latestPosition = createPosition({ positionSeconds: 80 }); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: latestPosition, + }); + + expect( + clearPlaybackPosition.mock.calls.map( + ([, contentXtreamId]) => contentXtreamId + ) + ).toEqual([LEGACY_ID, LEGACY_ID]); + expect(repositoryRows).toEqual([latestPosition]); + }); + + it('keeps legacy progress when the strict UI save rejects', async () => { + const legacyPosition = createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 22, + }); + repositoryRows = [legacyPosition]; + await startWithLoadedEpisode(); + savePlaybackPositionOrThrow.mockRejectedValueOnce(new Error('fail')); + const errorLog = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + expect( + fixture.componentInstance.handlePlaybackToggleRequestedFromUi({ + contentXtreamId: SCOPED_A_ID, + nextPosition: createPosition({ positionSeconds: 70 }), + }) + ).toBeUndefined(); + await settle(); + await fixture.whenStable(); + expect(errorLog).toHaveBeenCalled(); + expect(clearPlaybackPositionOrThrow).not.toHaveBeenCalled(); + expect( + fixture.componentInstance.episodePlaybackPositions() + .get(SCOPED_A_ID)?.positionSeconds + ).toBe(22); + }); + + it('ignores a deferred series-A response after selection changes to series B', async () => { + const seriesAResponse = createDeferred< + PlaybackPositionData[] + >(); + const seriesBLegacy = createPosition({ + contentXtreamId: LEGACY_ID, + seriesXtreamId: SERIES_B_ID, + positionSeconds: 77, + }); + getSeriesPlaybackPositions.mockImplementation( + ( + _playlistId: string, + seriesXtreamId: number + ): Promise => + seriesXtreamId === SERIES_A_ID + ? seriesAResponse.promise + : Promise.resolve([seriesBLegacy]) + ); + + await settle(); + selectedItem.set(createVodItem(SERIES_B_ID)); + await settle(); + fixture.componentInstance.vodSeriesSeasons.set([ + createSeason(SERIES_B_ID, [createProviderEpisode()]), + ]); + await settle(); + const scopedSeriesBId = Number( + fixture.componentInstance.mappedSeasons()['1'][0].id + ); + + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(scopedSeriesBId)?.positionSeconds + ).toBe(77); + + seriesAResponse.resolve([ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 11, + }), + ]); + await settle(); + + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(scopedSeriesBId)?.positionSeconds + ).toBe(77); + expect( + fixture.componentInstance.episodePlaybackPositions().size + ).toBe(1); + }); + + it('clears the previous playlist progress when the same series ID is selected', async () => { + repositoryRows = [ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 55, + }), + ]; + getSeriesPlaybackPositions.mockImplementation( + async ( + playlistId: string, + seriesXtreamId: number + ): Promise => + playlistId === PLAYLIST_ID + ? repositoryRows.filter( + (position) => + position.seriesXtreamId === + seriesXtreamId + ) + : [] + ); + await startWithLoadedEpisode(); + + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID)?.positionSeconds + ).toBe(55); + + currentPlaylist.set({ _id: 'playlist-2' }); + await settle(); + + expect( + fixture.componentInstance.episodePlaybackPositions().size + ).toBe(0); + expect(getSeriesPlaybackPositions).toHaveBeenLastCalledWith( + SECOND_PLAYLIST_ID, + SERIES_A_ID + ); + }); + + it('does not publish a deferred save after the playlist context switches away and back', async () => { + const saveCompletion = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + const secondPlaylistPosition = createPosition({ + playlistId: SECOND_PLAYLIST_ID, + positionSeconds: 80, + }); + const reloadedPosition = createPosition({ positionSeconds: 95 }); + let firstPlaylistLoads = 0; + getSeriesPlaybackPositions.mockImplementation( + async (playlistId: string): Promise => { + if (playlistId === SECOND_PLAYLIST_ID) { + return [secondPlaylistPosition]; + } + firstPlaylistLoads++; + return [ + firstPlaylistLoads === 1 + ? initialPosition + : reloadedPosition, + ]; + } + ); + savePlaybackPosition.mockImplementation(async () => { + await saveCompletion.promise; + }); + await startWithLoadedEpisode(); + + const pendingSave = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: createPosition({ positionSeconds: 60 }), + }); + await Promise.resolve(); + currentPlaylist.set({ _id: SECOND_PLAYLIST_ID }); + await settle(); + currentPlaylist.set({ _id: PLAYLIST_ID }); + await settle(); + const loadsBeforeSaveCompleted = firstPlaylistLoads; + + saveCompletion.resolve(); + await pendingSave; + await settle(); + + expect(loadsBeforeSaveCompleted).toBe(1); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(reloadedPosition); + }); + + it('does not publish a deferred clear after the playlist context switches away and back', async () => { + const clearCompletion = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + const secondPlaylistPosition = createPosition({ + playlistId: SECOND_PLAYLIST_ID, + positionSeconds: 80, + }); + const reloadedPosition = createPosition({ positionSeconds: 95 }); + let firstPlaylistLoads = 0; + getSeriesPlaybackPositions.mockImplementation( + async (playlistId: string): Promise => { + if (playlistId === SECOND_PLAYLIST_ID) { + return [secondPlaylistPosition]; + } + firstPlaylistLoads++; + return [ + firstPlaylistLoads === 1 + ? initialPosition + : reloadedPosition, + ]; + } + ); + clearPlaybackPosition.mockImplementation(async () => { + await clearCompletion.promise; + }); + await startWithLoadedEpisode(); + + const pendingClear = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: null, + }); + await Promise.resolve(); + currentPlaylist.set({ _id: SECOND_PLAYLIST_ID }); + await settle(); + currentPlaylist.set({ _id: PLAYLIST_ID }); + await settle(); + const loadsBeforeClearCompleted = firstPlaylistLoads; + + clearCompletion.resolve(); + await pendingClear; + await settle(); + + expect(loadsBeforeClearCompleted).toBe(1); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(reloadedPosition); + }); + + it('does not publish a deferred save after the playlist signal changes before the context effect', async () => { + const saveCompletion = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + repositoryRows = [initialPosition]; + savePlaybackPosition.mockImplementation(async () => { + await saveCompletion.promise; + }); + await startWithLoadedEpisode(); + + const pendingSave = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: createPosition({ positionSeconds: 60 }), + }); + await Promise.resolve(); + currentPlaylist.set({ _id: SECOND_PLAYLIST_ID }); + saveCompletion.resolve(); + await pendingSave; + + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(initialPosition); + }); + + it('does not publish a deferred clear after the series signal changes before the context effect', async () => { + const clearCompletion = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + repositoryRows = [initialPosition]; + clearPlaybackPosition.mockImplementation(async () => { + await clearCompletion.promise; + }); + await startWithLoadedEpisode(); + + const pendingClear = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: null, + }); + await Promise.resolve(); + selectedItem.set(createVodItem(SERIES_B_ID)); + clearCompletion.resolve(); + await pendingClear; + + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(initialPosition); + }); + + it('serializes a slow save before a later clear so the clear wins', async () => { + const saveCompletion = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + const savedPosition = createPosition({ positionSeconds: 60 }); + repositoryRows = [initialPosition]; + savePlaybackPosition.mockImplementation( + async ( + _playlistId: string, + position: PlaybackPositionData + ): Promise => { + repositoryOrder.push('save:start'); + await saveCompletion.promise; + repositoryRows = repositoryRows.filter( + (row) => + row.contentXtreamId !== + position.contentXtreamId + ); + repositoryRows.push(position); + repositoryOrder.push('save:end'); + } + ); + clearPlaybackPosition.mockImplementation( + async ( + _playlistId: string, + contentXtreamId: number + ): Promise => { + repositoryOrder.push('clear'); + repositoryRows = repositoryRows.filter( + (row) => row.contentXtreamId !== contentXtreamId + ); + } + ); + await startWithLoadedEpisode(); + + const pendingSave = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: savedPosition, + }); + await Promise.resolve(); + const pendingClear = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: null, + }); + await Promise.resolve(); + saveCompletion.resolve(); + await Promise.all([pendingSave, pendingClear]); + await settle(); + + expect(repositoryOrder).toEqual([ + 'save:start', + 'save:end', + 'clear', + ]); + expect(repositoryRows).toEqual([]); + expect( + fixture.componentInstance.episodePlaybackPositions().size + ).toBe(0); + }); + + it('serializes a slow clear before a later save so the save wins', async () => { + const clearCompletion = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + const savedPosition = createPosition({ positionSeconds: 70 }); + repositoryRows = [initialPosition]; + clearPlaybackPosition.mockImplementation( + async ( + _playlistId: string, + contentXtreamId: number + ): Promise => { + repositoryOrder.push('clear:start'); + await clearCompletion.promise; + repositoryRows = repositoryRows.filter( + (row) => row.contentXtreamId !== contentXtreamId + ); + repositoryOrder.push('clear:end'); + } + ); + savePlaybackPosition.mockImplementation( + async ( + _playlistId: string, + position: PlaybackPositionData + ): Promise => { + repositoryOrder.push('save'); + repositoryRows = repositoryRows.filter( + (row) => + row.contentXtreamId !== + position.contentXtreamId + ); + repositoryRows.push(position); + } + ); + await startWithLoadedEpisode(); + + const pendingClear = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: null, + }); + await Promise.resolve(); + const pendingSave = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: savedPosition, + }); + await Promise.resolve(); + clearCompletion.resolve(); + await Promise.all([pendingClear, pendingSave]); + await settle(); + + expect(repositoryOrder).toEqual([ + 'clear:start', + 'clear:end', + 'save', + ]); + expect(repositoryRows).toEqual([savedPosition]); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(savedPosition); + }); + + it('does not let a load started before a save overwrite the saved position', async () => { + const staleLoad = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + const savedPosition = createPosition({ positionSeconds: 70 }); + repositoryRows = [initialPosition]; + await startWithLoadedEpisode(); + getSeriesPlaybackPositions.mockImplementationOnce( + () => staleLoad.promise + ); + + selectedItem.set(createVodItem(SERIES_A_ID)); + await settle(); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: savedPosition, + }); + await settle(); + staleLoad.resolve([initialPosition]); + await settle(); + + expect(repositoryRows).toEqual([savedPosition]); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(savedPosition); + }); + + it('does not let a load started before a clear resurrect the cleared position', async () => { + const staleLoad = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + repositoryRows = [initialPosition]; + await startWithLoadedEpisode(); + getSeriesPlaybackPositions.mockImplementationOnce( + () => staleLoad.promise + ); + + selectedItem.set(createVodItem(SERIES_A_ID)); + await settle(); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: null, + }); + await settle(); + staleLoad.resolve([initialPosition]); + await settle(); + + expect(repositoryRows).toEqual([]); + expect( + fixture.componentInstance.episodePlaybackPositions().size + ).toBe(0); + }); + + it('reloads unrelated episode rows after a save invalidates a pending load', async () => { + const { firstPosition, pendingLoad, secondPosition } = + await startPendingTwoEpisodeLoad(); + const savedPosition = { + ...firstPosition, + positionSeconds: 70, + }; + + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: firstPosition.contentXtreamId, + nextPosition: savedPosition, + }); + await settle(); + pendingLoad.resolve([firstPosition, secondPosition]); + await settle(); + + expect(getSeriesPlaybackPositions).toHaveBeenCalledTimes(3); + expect( + fixture.componentInstance.episodePlaybackPositions() + ).toEqual( + new Map([ + [savedPosition.contentXtreamId, savedPosition], + [secondPosition.contentXtreamId, secondPosition], + ]) + ); + }); + + it('reloads all episode rows after a mutation invalidates a pending load and rejects', async () => { + const { firstPosition, pendingLoad, secondPosition } = + await startPendingTwoEpisodeLoad(); + const saveError = new Error('save failed'); + savePlaybackPositionOrThrow.mockRejectedValue(saveError); + + const mutation = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: firstPosition.contentXtreamId, + nextPosition: { + ...firstPosition, + positionSeconds: 70, + }, + }); + await expect(mutation).rejects.toBe(saveError); + await settle(); + pendingLoad.resolve([firstPosition, secondPosition]); + await settle(); + + expect(getSeriesPlaybackPositions).toHaveBeenCalledTimes(3); + expect( + fixture.componentInstance.episodePlaybackPositions() + ).toEqual( + new Map([ + [firstPosition.contentXtreamId, firstPosition], + [secondPosition.contentXtreamId, secondPosition], + ]) + ); + }); + + it('migrates inline progress before cleanup and does not restore the legacy alias', async () => { + repositoryRows = [ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 25, + }), + ]; + await startWithLoadedEpisode(); + fixture.componentInstance.inlinePlayback.set({ + streamUrl: 'https://example.test/episode.mpg', + title: 'Series 100 - Pilot', + contentInfo: { + playlistId: PLAYLIST_ID, + contentXtreamId: SCOPED_A_ID, + contentType: 'episode', + seriesXtreamId: SERIES_A_ID, + seasonNumber: 1, + episodeNumber: 1, + }, + }); + + fixture.componentInstance.handleInlineTimeUpdate({ + currentTime: 60, + duration: 100, + }); + await settle(); + + expect(repositoryOrder).toEqual([ + `save:${SCOPED_A_ID}`, + `clear:${LEGACY_ID}`, + ]); + expect(repositoryRows.map((row) => row.contentXtreamId)).toEqual([ + SCOPED_A_ID, + ]); + + const seasons = fixture.componentInstance.vodSeriesSeasons(); + fixture.componentInstance.vodSeriesSeasons.set( + seasons.map((season) => ({ + ...season, + episodes: [...season.episodes], + })) + ); + await settle(); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: createPosition({ positionSeconds: 70 }), + }); + + expect( + repositoryOrder.filter( + (entry) => entry === `clear:${LEGACY_ID}` + ) + ).toHaveLength(1); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID)?.positionSeconds + ).toBe(70); + expect(getSeriesPlaybackPositions).toHaveBeenCalledTimes(1); + }); + + it('repeats a matching runtime save so the view can clear confirmed legacy progress', async () => { + repositoryRows = [ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 30, + }), + ]; + await startWithLoadedEpisode(); + const runtimePosition = createPosition({ positionSeconds: 65 }); + + expect(runtimePositionListener).toBeDefined(); + runtimePositionListener?.(runtimePosition); + await settle(); + + expect(repositoryOrder).toEqual([ + `save:${SCOPED_A_ID}`, + `clear:${LEGACY_ID}`, + ]); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(runtimePosition); + }); + + it('clears confirmed legacy before scoped watched state without resurrection after refetch', async () => { + const exactPosition = createPosition({ + positionSeconds: 100, + durationSeconds: 100, + }); + repositoryRows = [ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 20, + }), + exactPosition, + ]; + await startWithLoadedEpisode(); + + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: null, + }); + await settle(); + + expect(repositoryOrder).toEqual([ + `clear:${LEGACY_ID}`, + `clear:${SCOPED_A_ID}`, + ]); + expect(repositoryRows).toEqual([]); + expect( + fixture.componentInstance.episodePlaybackPositions().size + ).toBe(0); + + currentPlaylist.set(null); + await settle(); + currentPlaylist.set({ _id: PLAYLIST_ID }); + await settle(); + + expect( + fixture.componentInstance.episodePlaybackPositions().size + ).toBe(0); + expect(getSeriesPlaybackPositions).toHaveBeenCalledTimes(2); + }); + + it('does not clear legacy state for regular-series episodes', async () => { + selectedContentType.set('series'); + selectedItem.set(createRegularItem(SERIES_A_ID)); + serialSeasonsResource.set([ + { + id: 'season-1', + name: 'Season 1', + cmd: '/media/file_100.mpg', + series: [1], + }, + ]); + repositoryRows = [ + createPosition({ + contentXtreamId: REGULAR_ID, + positionSeconds: 45, + }), + ]; + await settle(); + + const watchedPosition = createPosition({ + contentXtreamId: REGULAR_ID, + positionSeconds: 100, + }); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: REGULAR_ID, + nextPosition: watchedPosition, + }); + + expect(savePlaybackPosition).toHaveBeenCalledWith( + PLAYLIST_ID, + watchedPosition + ); + expect(clearPlaybackPosition).not.toHaveBeenCalled(); + }); + + it('does not clear a coordinate-conflicting legacy row', async () => { + const exactPosition = createPosition({ positionSeconds: 50 }); + repositoryRows = [ + exactPosition, + createPosition({ + contentXtreamId: LEGACY_ID, + seasonNumber: 9, + positionSeconds: 25, + }), + ]; + await startWithLoadedEpisode(); + + const watchedPosition = createPosition({ positionSeconds: 100 }); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: watchedPosition, + }); + + expect(savePlaybackPosition).toHaveBeenCalledWith( + PLAYLIST_ID, + watchedPosition + ); + expect(clearPlaybackPosition).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts b/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts index 83d6d991b..275cbffdb 100644 --- a/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts +++ b/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts @@ -199,6 +199,130 @@ describe('PlaybackPositionRuntimeBridgeService', () => { expect(service.onPlaybackPositionUpdate(callback)).toBe(unsubscribe); expect(onPlaybackPositionUpdate).toHaveBeenCalledWith(callback); }); + + describe.each([ + { + name: 'save', + installBridge: (implementation: jest.Mock) => { + window.electron = { + ...window.electron, + dbSavePlaybackPosition: implementation, + } as unknown as typeof window.electron; + }, + invokeLenient: ( + target: PlaybackPositionRuntimeBridgeService + ) => target.savePlaybackPosition('playlist-1', createPosition()), + invokeStrict: ( + target: PlaybackPositionRuntimeBridgeService + ) => + target.savePlaybackPositionOrThrow( + 'playlist-1', + createPosition() + ), + }, + { + name: 'clear', + installBridge: (implementation: jest.Mock) => { + window.electron = { + ...window.electron, + dbClearPlaybackPosition: implementation, + } as unknown as typeof window.electron; + }, + invokeLenient: ( + target: PlaybackPositionRuntimeBridgeService + ) => + target.clearPlaybackPosition( + 'playlist-1', + 100, + 'vod' + ), + invokeStrict: ( + target: PlaybackPositionRuntimeBridgeService + ) => + target.clearPlaybackPositionOrThrow( + 'playlist-1', + 100, + 'vod' + ), + }, + ])('$name persistence', (operation) => { + it('accepts only an explicit success result', async () => { + runtimeCapabilities.supportsPlaybackPositionStorage = true; + operation.installBridge( + jest.fn().mockResolvedValue({ success: true }) + ); + + await expect(operation.invokeStrict(service)).resolves.toBeUndefined(); + }); + + it('propagates rejected IPC', async () => { + const error = new Error('database is locked'); + runtimeCapabilities.supportsPlaybackPositionStorage = true; + operation.installBridge(jest.fn().mockRejectedValue(error)); + + await expect(operation.invokeStrict(service)).rejects.toBe(error); + }); + + it.each([{ success: false }, {}, undefined])( + 'rejects a non-success result %#', + async (result) => { + runtimeCapabilities.supportsPlaybackPositionStorage = true; + operation.installBridge(jest.fn().mockResolvedValue(result)); + + await expect(operation.invokeStrict(service)).rejects.toThrow( + 'did not succeed' + ); + } + ); + + it('rejects when the storage capability is unavailable', async () => { + const bridgeMethod = jest + .fn() + .mockResolvedValue({ success: true }); + operation.installBridge(bridgeMethod); + + await expect(operation.invokeStrict(service)).rejects.toThrow( + 'storage is unavailable' + ); + expect(bridgeMethod).not.toHaveBeenCalled(); + }); + + it('rejects when the expected bridge method is unavailable', async () => { + runtimeCapabilities.supportsPlaybackPositionStorage = true; + + await expect(operation.invokeStrict(service)).rejects.toThrow( + 'method is unavailable' + ); + }); + + it.each([{ success: false }, {}, undefined])( + 'ignores a non-success result through the lenient method %#', + async (result) => { + runtimeCapabilities.supportsPlaybackPositionStorage = true; + operation.installBridge(jest.fn().mockResolvedValue(result)); + + await expect( + operation.invokeLenient(service) + ).resolves.toBeUndefined(); + } + ); + + it('resolves the lenient method when its bridge method is missing', async () => { + runtimeCapabilities.supportsPlaybackPositionStorage = true; + + await expect( + operation.invokeLenient(service) + ).resolves.toBeUndefined(); + }); + + it('propagates rejected IPC through the lenient method', async () => { + const error = new Error('database is locked'); + runtimeCapabilities.supportsPlaybackPositionStorage = true; + operation.installBridge(jest.fn().mockRejectedValue(error)); + + await expect(operation.invokeLenient(service)).rejects.toBe(error); + }); + }); }); function createPosition( diff --git a/libs/services/src/lib/playback-position-runtime-bridge.service.ts b/libs/services/src/lib/playback-position-runtime-bridge.service.ts index d577ddd66..ca5eea600 100644 --- a/libs/services/src/lib/playback-position-runtime-bridge.service.ts +++ b/libs/services/src/lib/playback-position-runtime-bridge.service.ts @@ -65,6 +65,27 @@ export class PlaybackPositionRuntimeBridgeService { await this.bridge?.dbSavePlaybackPosition?.(playlistId, data); } + async savePlaybackPositionOrThrow( + playlistId: string, + data: PlaybackPositionData + ): Promise { + if (!this.supportsStorage) { + throw new Error('Playback position storage is unavailable'); + } + + const bridge = this.bridge; + if (typeof bridge?.dbSavePlaybackPosition !== 'function') { + throw new Error( + 'Playback position save method is unavailable' + ); + } + + const result = await bridge.dbSavePlaybackPosition(playlistId, data); + if (result?.success !== true) { + throw new Error('Playback position save did not succeed'); + } + } + getPlaybackPosition( playlistId: string, contentXtreamId: number, @@ -150,6 +171,32 @@ export class PlaybackPositionRuntimeBridgeService { ); } + async clearPlaybackPositionOrThrow( + playlistId: string, + contentXtreamId: number, + contentType: PlaybackPositionContentType + ): Promise { + if (!this.supportsStorage) { + throw new Error('Playback position storage is unavailable'); + } + + const bridge = this.bridge; + if (typeof bridge?.dbClearPlaybackPosition !== 'function') { + throw new Error( + 'Playback position clear method is unavailable' + ); + } + + const result = await bridge.dbClearPlaybackPosition( + playlistId, + contentXtreamId, + contentType + ); + if (result?.success !== true) { + throw new Error('Playback position clear did not succeed'); + } + } + onPlaybackPositionUpdate( callback: (data: PlaybackPositionData) => void ): (() => void) | undefined { diff --git a/libs/shared/database/src/lib/connection.spec.ts b/libs/shared/database/src/lib/connection.spec.ts index 09d00ee75..031faf932 100644 --- a/libs/shared/database/src/lib/connection.spec.ts +++ b/libs/shared/database/src/lib/connection.spec.ts @@ -18,6 +18,10 @@ function createdObjectNames(prefix: string, statements: readonly string[]) { } describe('database schema statements', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + const { createTableStatements, columnMigrationStatements, @@ -37,6 +41,26 @@ describe('database schema statements', () => { } ).ensureDownloadsPauseResumeSchema; + it('records only the statement type for expanded main-process SQL', () => { + jest.spyOn(console, 'log').mockImplementation(() => undefined); + const secrets = [ + 'main-user-secret', + 'main-password-secret', + 'https://main-user:main-password@example.com/live?token=main-token-secret', + ]; + + __databaseConnectionTestHooks.traceSqlStatement( + `UPDATE playlists SET username = '${secrets[0]}', password = '${secrets[1]}', url = '${secrets[2]}'` + ); + + const output = (console.log as jest.Mock).mock.calls.flat().join('\n'); + expect(output).toContain('"statementType":"UPDATE"'); + expect(output).not.toContain('UPDATE playlists'); + for (const secret of secrets) { + expect(output).not.toContain(secret); + } + }); + function createRebuildSqlite(legacyTableSql: string | undefined) { const statements: string[] = []; const transaction = jest.fn((callback: () => void) => callback); diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index c4c664b0e..0721cf11f 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -12,6 +12,10 @@ import Database from 'better-sqlite3'; import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { + redactSensitiveData, + summarizeSqlStatementForTrace, +} from '@iptvnator/shared/logging'; import * as schema from './schema'; import { getIptvnatorDatabasePath } from './path-utils'; @@ -49,13 +53,6 @@ function isSqlTraceEnabled(): boolean { ); } -function compactSqlForTrace(sql: string): string { - const compactSql = sql.replace(/\s+/g, ' ').trim(); - return compactSql.length <= 180 - ? compactSql - : `${compactSql.slice(0, 177)}...`; -} - function traceSql(scope: string, message: string, payload?: unknown): void { if (payload === undefined) { console.log(`[IPTVnator Trace][${scope}] ${message}`); @@ -63,10 +60,16 @@ function traceSql(scope: string, message: string, payload?: unknown): void { } console.log( - `[IPTVnator Trace][${scope}] ${message} ${JSON.stringify(payload)}` + `[IPTVnator Trace][${scope}] ${message} ${JSON.stringify( + redactSensitiveData(payload) + )}` ); } +function traceSqlStatement(sql: unknown): void { + traceSql('sql-main', 'query', summarizeSqlStatementForTrace(sql)); +} + /** * Get the database file path */ @@ -402,6 +405,7 @@ export const __databaseConnectionTestHooks = { backfillEpgProgramSourceUrls, cleanupLegacyTmdbSearchCache, runMigrations, + traceSqlStatement, } as const; /** @@ -1119,11 +1123,7 @@ export async function initDatabase( sqlite = new Database(filePath, { readonly, verbose: isSqlTraceEnabled() - ? (message?: unknown) => { - traceSql('sql-main', 'query', { - sql: compactSqlForTrace(String(message ?? '')), - }); - } + ? (message?: unknown) => traceSqlStatement(message) : undefined, }); diff --git a/libs/shared/interfaces/src/lib/stalker-item.normalizer.spec.ts b/libs/shared/interfaces/src/lib/stalker-item.normalizer.spec.ts index 728d90bdb..2f09e8ba2 100644 --- a/libs/shared/interfaces/src/lib/stalker-item.normalizer.spec.ts +++ b/libs/shared/interfaces/src/lib/stalker-item.normalizer.spec.ts @@ -23,6 +23,19 @@ describe('extractStalkerItemType', () => { }) ).toBe('live'); }); + + it.each([true, 1, '1'] as const)( + 'treats is_series=%p as a series item', + (isSeries) => { + expect( + extractStalkerItemType({ + id: '50001', + title: 'Portal Series', + is_series: isSeries, + }) + ).toBe('series'); + } + ); }); describe('isStalkerRadioItem', () => { diff --git a/libs/shared/interfaces/src/lib/stalker-item.normalizer.ts b/libs/shared/interfaces/src/lib/stalker-item.normalizer.ts index 6f33a61df..2c630fef2 100644 --- a/libs/shared/interfaces/src/lib/stalker-item.normalizer.ts +++ b/libs/shared/interfaces/src/lib/stalker-item.normalizer.ts @@ -50,7 +50,7 @@ export function extractStalkerItemPoster( * Determine the normalised activity type of a Stalker item. * * - `itv` / `live` / radio → `'live'` - * - `series` or `is_series` truthy → `'series'` + * - `series` or `is_series` equal to `true`, `1`, or `'1'` → `'series'` * - everything else → `'movie'` */ export function extractStalkerItemType( diff --git a/libs/shared/logging/package.json b/libs/shared/logging/package.json new file mode 100644 index 000000000..dc1a82d44 --- /dev/null +++ b/libs/shared/logging/package.json @@ -0,0 +1,11 @@ +{ + "name": "@iptvnator/shared/logging", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "tslib": "^2.3.0" + } +} diff --git a/libs/shared/logging/project.json b/libs/shared/logging/project.json index 031ec83b4..083c8ab35 100644 --- a/libs/shared/logging/project.json +++ b/libs/shared/logging/project.json @@ -5,6 +5,16 @@ "projectType": "library", "tags": ["scope:shared", "domain:shared-runtime", "type:util"], "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/shared/logging", + "main": "libs/shared/logging/src/index.ts", + "tsConfig": "libs/shared/logging/tsconfig.lib.json", + "assets": [] + } + }, "test": { "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], diff --git a/libs/shared/logging/src/index.ts b/libs/shared/logging/src/index.ts index 99feec49b..7a64299d0 100644 --- a/libs/shared/logging/src/index.ts +++ b/libs/shared/logging/src/index.ts @@ -3,6 +3,14 @@ export { redactSensitiveData, } from './lib/redact-sensitive-data'; export type { RedactionOptions } from './lib/redact-sensitive-data'; +export { + SQL_TRACE_STATEMENT_TYPE, + summarizeSqlStatementForTrace, +} from './lib/sql-trace-summary'; +export type { + SqlTraceStatementType, + SqlTraceSummary, +} from './lib/sql-trace-summary'; export { measureRendererPerformancePhase, RENDERER_PERFORMANCE_PHASE, diff --git a/libs/shared/logging/src/lib/sql-trace-summary.spec.ts b/libs/shared/logging/src/lib/sql-trace-summary.spec.ts new file mode 100644 index 000000000..bc72bf49b --- /dev/null +++ b/libs/shared/logging/src/lib/sql-trace-summary.spec.ts @@ -0,0 +1,58 @@ +import { summarizeSqlStatementForTrace } from './sql-trace-summary'; + +describe('summarizeSqlStatementForTrace', () => { + it.each([ + [ + 'SELECT', + `SELECT * FROM playlists WHERE username = 'trace-user-secret' AND password = 'trace-password-secret' AND token = 'trace-token-secret'`, + ], + [ + 'INSERT', + `INSERT INTO playlists (name) VALUES ('O''Brien-secret')`, + ], + [ + 'UPDATE', + `UPDATE playlists SET url = 'https://url-user-secret:url-password-secret@example.com/live?token=url-token-secret'`, + ], + [ + 'DELETE', + `DELETE FROM content WHERE id = 987654321 AND payload = X'7365637265742D626C6F62'`, + ], + [ + 'SELECT', + ` \n\tSeLeCt * FROM content WHERE rating = 12345.6789`, + ], + [ + 'WITH', + `WITH credentials AS (SELECT 'with-secret') SELECT * FROM credentials`, + ], + ])('returns only the %s statement type', (statementType, sql) => { + const summary = summarizeSqlStatementForTrace(sql); + const serialized = JSON.stringify(summary); + + expect(summary).toEqual({ statementType }); + expect(serialized).toBe(`{"statementType":"${statementType}"}`); + expect(serialized).not.toContain('secret'); + expect(serialized).not.toContain(`O''Brien-secret`); + expect(serialized).not.toContain('987654321'); + expect(serialized).not.toContain('7365637265742D626C6F62'); + expect(serialized).not.toContain('12345.6789'); + expect(serialized).not.toContain('length'); + }); + + it.each([ + ` \n-- SELECT 'comment-secret'\nSELECT * FROM playlists`, + `\t/* INSERT 'comment-secret' */ SELECT * FROM playlists`, + `VACUUMINTO 'malicious-secret'`, + `SELECTpassword FROM credentials`, + `DO 'unrecognized-secret'`, + `'; DROP TABLE playlists; -- malicious-secret`, + '', + undefined, + null, + ])('maps comments and unrecognized input to OTHER', (sql) => { + expect(summarizeSqlStatementForTrace(sql)).toEqual({ + statementType: 'OTHER', + }); + }); +}); diff --git a/libs/shared/logging/src/lib/sql-trace-summary.ts b/libs/shared/logging/src/lib/sql-trace-summary.ts new file mode 100644 index 000000000..60f4995b7 --- /dev/null +++ b/libs/shared/logging/src/lib/sql-trace-summary.ts @@ -0,0 +1,58 @@ +export const SQL_TRACE_STATEMENT_TYPE = { + SELECT: 'SELECT', + INSERT: 'INSERT', + UPDATE: 'UPDATE', + DELETE: 'DELETE', + REPLACE: 'REPLACE', + CREATE: 'CREATE', + ALTER: 'ALTER', + DROP: 'DROP', + PRAGMA: 'PRAGMA', + WITH: 'WITH', + BEGIN: 'BEGIN', + COMMIT: 'COMMIT', + ROLLBACK: 'ROLLBACK', + SAVEPOINT: 'SAVEPOINT', + RELEASE: 'RELEASE', + VACUUM: 'VACUUM', + ANALYZE: 'ANALYZE', + REINDEX: 'REINDEX', + ATTACH: 'ATTACH', + DETACH: 'DETACH', + EXPLAIN: 'EXPLAIN', + OTHER: 'OTHER', +} as const; + +export type SqlTraceStatementType = + (typeof SQL_TRACE_STATEMENT_TYPE)[keyof typeof SQL_TRACE_STATEMENT_TYPE]; + +export interface SqlTraceSummary { + statementType: SqlTraceStatementType; +} + +const ALLOWED_STATEMENT_TYPES = new Set( + Object.values(SQL_TRACE_STATEMENT_TYPE).filter( + (statementType) => + statementType !== SQL_TRACE_STATEMENT_TYPE.OTHER + ) as SqlTraceStatementType[] +); + +const SQL_STATEMENT_PREFIX = /^\s*([A-Za-z]+)(?=[\s(;]|$)/; + +export function summarizeSqlStatementForTrace(sql: unknown): SqlTraceSummary { + if (typeof sql !== 'string') { + return { statementType: SQL_TRACE_STATEMENT_TYPE.OTHER }; + } + + const match = SQL_STATEMENT_PREFIX.exec(sql); + const candidate = match?.[1]?.toUpperCase() as + | SqlTraceStatementType + | undefined; + + return { + statementType: + candidate && ALLOWED_STATEMENT_TYPES.has(candidate) + ? candidate + : SQL_TRACE_STATEMENT_TYPE.OTHER, + }; +} diff --git a/libs/ui/styles/_content-grid.scss b/libs/ui/styles/_content-grid.scss index 096df6fb9..8906db632 100644 --- a/libs/ui/styles/_content-grid.scss +++ b/libs/ui/styles/_content-grid.scss @@ -1,7 +1,9 @@ // Shared content grid and card styles // -// Usage (after adding libs/ui/styles to stylePreprocessorOptions.includePaths): -// @use 'content-grid' as grid; +// Choose a relative @use path from each consuming stylesheet. A current +// portal-shared component uses its local forwarding module as shown below; +// direct consumers of this partial may need a different depth. +// @use '../../styles/content-grid' as grid; // @include grid.content-grid; // @include grid.content-card; diff --git a/libs/ui/styles/_index.scss b/libs/ui/styles/_index.scss index 06421af87..c038c32ef 100644 --- a/libs/ui/styles/_index.scss +++ b/libs/ui/styles/_index.scss @@ -1,13 +1,12 @@ -// ─── IPTVnator UI styles library ───────────────────────────────────────────── -// Add libs/ui/styles to stylePreprocessorOptions.includePaths in project.json -// to import these without relative paths: +// Canonical forwarding inventory for shared IPTVnator UI styles. +// The workspace has no global Sass include path for this directory; production +// consumers use a relative path to the partial they need, for example: // -// @use 'portal-layout' as portal; -// @use 'content-grid' as grid; -// @use 'portal-sidebar'; +// @use '../../../../../../ui/styles/portal-layout' as portal; @forward 'portal-layout'; @forward 'content-grid'; @forward 'portal-sidebar'; +@forward 'panel-header'; @forward 'detail-view'; @forward 'detail-view-actions'; diff --git a/libs/ui/styles/_portal-layout.scss b/libs/ui/styles/_portal-layout.scss index f2338e805..5075f3333 100644 --- a/libs/ui/styles/_portal-layout.scss +++ b/libs/ui/styles/_portal-layout.scss @@ -4,8 +4,10 @@ // Include this mixin in a component's SCSS to get the shared flex host+sidebar // skeleton, then add only the component-specific rules underneath. // -// Usage (after adding libs/ui/styles to stylePreprocessorOptions.includePaths): -// @use 'portal-layout' as portal; +// Choose the relative @use path from each consuming stylesheet. Current portal +// live layouts use the example below; other consumers may need a different +// depth. +// @use '../../../../../../ui/styles/portal-layout' as portal; // @include portal.live-layout; @mixin live-layout { diff --git a/libs/ui/styles/_portal-sidebar.scss b/libs/ui/styles/_portal-sidebar.scss index 44813b427..3902d5d02 100644 --- a/libs/ui/styles/_portal-sidebar.scss +++ b/libs/ui/styles/_portal-sidebar.scss @@ -1,8 +1,10 @@ // ─── Shared sidebar + sidebar-header styles ─────────────────────────────────── // Plain CSS rules (not a mixin) used for M3U/legacy sidebar layouts. // -// Usage (after adding libs/ui/styles to stylePreprocessorOptions.includePaths): -// @use 'portal-sidebar'; (no alias needed — no exported members to call) +// Choose the relative @use path from each consuming stylesheet. Current portal +// live layouts use the example below; other consumers may need a different +// depth. No alias is needed because this partial exports no callable members. +// @use '../../../../../../ui/styles/portal-sidebar'; .sidebar { // Width is controlled by the resizable directive diff --git a/package.json b/package.json index 23215675c..3c6b8f80e 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "serve:website": "nx serve website", "build:website": "nx build website", "i18n:check": "node tools/i18n/check-drift.mjs", + "skills:validate": "node tools/skills/validate-repository-skills.mjs", "release:artwork:dry-run": "tsx --tsconfig tsconfig.base.json tools/release/generate-marketing-artwork.ts --dry-run", "release:artwork:manifest": "tsx --tsconfig tsconfig.base.json tools/release/generate-marketing-artwork.ts --manifest", "release:artwork:generate": "tsx --tsconfig tsconfig.base.json tools/release/generate-marketing-artwork.ts --generate", diff --git a/tools/coverage/coverage-policy.json b/tools/coverage/coverage-policy.json index ebd6f52eb..d9320dd7b 100644 --- a/tools/coverage/coverage-policy.json +++ b/tools/coverage/coverage-policy.json @@ -277,6 +277,12 @@ "validationCommand": "pnpm nx test release-tools", "reason": "Node tests validate release-note parsing, rendering and gate policy; the scripts are release tooling, not shipped source." }, + { + "name": "repository-skills", + "root": "tools/skills", + "validationCommand": "pnpm nx test repository-skills", + "reason": "Node tests validate committed repository-skill metadata and documented paths; the validator is agent tooling, not shipped source." + }, { "name": "eslint-tools", "root": "tools/eslint", diff --git a/tools/release/extract-changelog-section.mjs b/tools/release/extract-changelog-section.mjs index 65a5aa697..09103104e 100644 --- a/tools/release/extract-changelog-section.mjs +++ b/tools/release/extract-changelog-section.mjs @@ -24,6 +24,11 @@ const workspaceRoot = path.resolve( '../..' ); +const INTERNAL_DETAILS_BLOCK = + /(?:^|\n\n)
\nInternal changes<\/summary>\n\n[\s\S]*?\n\n<\/details>(?=\n\n|$)/g; +const CLI_USAGE = + 'Usage: extract-changelog-section.mjs [--public] '; + /** * @param {string} changelog full CHANGELOG.md content * @param {string} version bare semver, e.g. `0.24.0` @@ -59,38 +64,99 @@ export function extractSection(changelog, version) { return lines.slice(start + 1, end).join('\n').trim(); } -function main() { - const version = process.argv[2]; +/** + * @param {string} changelog full CHANGELOG.md content + * @param {string} version bare semver, e.g. `0.24.0` + * @returns {string | null} public section body without internal details + */ +export function extractPublicSection(changelog, version) { + const section = extractSection(changelog, version); - if (!version || !/^\d+\.\d+\.\d+$/.test(version)) { - console.error( - 'Usage: extract-changelog-section.mjs (for example 0.24.0)' - ); - process.exit(2); + return section === null + ? null + : section.replace(INTERNAL_DETAILS_BLOCK, '').trim(); +} + +export function parseExtractArguments(args) { + const publicFlagCount = args.filter( + (argument) => argument === '--public' + ).length; + const positional = args.filter( + (argument) => argument !== '--public' + ); + + if ( + publicFlagCount > 1 || + positional.length !== 1 || + !/^\d+\.\d+\.\d+$/.test(positional[0]) + ) { + return null; } - const changelogPath = path.join(workspaceRoot, 'CHANGELOG.md'); - const section = extractSection(readFileSync(changelogPath, 'utf8'), version); + return { + version: positional[0], + publicOnly: publicFlagCount === 1, + }; +} + +export function runExtractorCli(changelog, args) { + const options = parseExtractArguments(args); + + if (options === null) { + return { exitCode: 2, stdout: '', stderr: `${CLI_USAGE}\n` }; + } + + const section = options.publicOnly + ? extractPublicSection(changelog, options.version) + : extractSection(changelog, options.version); if (section === null) { - console.error( - [ - `CHANGELOG.md has no section for ${version}.`, + return { + exitCode: 1, + stdout: '', + stderr: `${[ + `CHANGELOG.md has no section for ${options.version}.`, 'The release flow writes it before tagging:', ' pnpm run release:notes:changelog', ' node tools/release/build-release-notes.mjs --consume', 'Commit the changelog, then re-tag.', - ].join('\n') - ); - process.exit(1); + ].join('\n')}\n`, + }; } - if (section === '') { - console.error(`CHANGELOG.md section for ${version} is empty.`); - process.exit(1); + if (section === '' && !options.publicOnly) { + return { + exitCode: 1, + stdout: '', + stderr: `CHANGELOG.md section for ${options.version} is empty.\n`, + }; } - process.stdout.write(`${section}\n`); + return { + exitCode: 0, + stdout: section === '' ? '' : `${section}\n`, + stderr: '', + }; +} + +export function runExtractorProcess(args, readChangelog) { + if (parseExtractArguments(args) === null) { + return runExtractorCli('', args); + } + + return runExtractorCli(readChangelog(), args); +} + +function main() { + const changelogPath = path.join(workspaceRoot, 'CHANGELOG.md'); + const result = runExtractorProcess( + process.argv.slice(2), + () => readFileSync(changelogPath, 'utf8') + ); + + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + process.exitCode = result.exitCode; } // Allow importing extractSection from tests without running the CLI. diff --git a/tools/release/project.json b/tools/release/project.json index 0dec473ec..e9003ea6a 100644 --- a/tools/release/project.json +++ b/tools/release/project.json @@ -15,6 +15,7 @@ "{workspaceRoot}/tools/release/build-release-notes.mjs", "{workspaceRoot}/tools/release/screenshot-guards.mjs", "{workspaceRoot}/tools/release/screenshots.manifest.json", + "{workspaceRoot}/.github/workflows/build-and-make.yaml", "{workspaceRoot}/tools/release/release-notes.test.mjs", "{workspaceRoot}/tools/release/release-note-gate.test.mjs", "{workspaceRoot}/tools/release/build-release-notes.test.mjs", diff --git a/tools/release/release-notes.test.mjs b/tools/release/release-notes.test.mjs index 6445d5f59..86c0eb5ad 100644 --- a/tools/release/release-notes.test.mjs +++ b/tools/release/release-notes.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { after, describe, it } from 'node:test'; @@ -18,7 +18,13 @@ import { renderGithubBody, upsertChangelogSection, } from './release-notes-render.mjs'; -import { extractSection } from './extract-changelog-section.mjs'; +import { + extractPublicSection, + extractSection, + parseExtractArguments, + runExtractorCli, + runExtractorProcess, +} from './extract-changelog-section.mjs'; const tempDirs = []; @@ -443,33 +449,45 @@ describe('upsertChangelogSection', () => { }); }); +const changelog = [ + '# Changelog', + '', + 'Intro paragraph with a pointer.', + '', + '', + '', + '# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-08-01)', + '', + '### Features', + '', + '- **playback** — Up Next rail.', + '', + '
', + 'Internal changes', + '', + '- **deps** — parser bump.', + '', + '
', + '', + '# [0.12.0](https://github.com/4gray/iptvnator/compare/v0.11.1...v0.12.0) (2023-03-11)', + '', + '### Bug Fixes', + '', + '- old entry', +].join('\n'); + +const internalOnlyChangelog = [ + '# 0.24.0 (2026-08-01)', + '', + '
', + 'Internal changes', + '', + '- **deps** — parser bump.', + '', + '
', +].join('\n'); + describe('extractSection', () => { - const changelog = [ - '# Changelog', - '', - 'Intro paragraph with a pointer.', - '', - '', - '', - '# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-08-01)', - '', - '### Features', - '', - '- **playback** — Up Next rail.', - '', - '
', - 'Internal changes', - '', - '- **deps** — parser bump.', - '', - '
', - '', - '# [0.12.0](https://github.com/4gray/iptvnator/compare/v0.11.1...v0.12.0) (2023-03-11)', - '', - '### Bug Fixes', - '', - '- old entry', - ].join('\n'); it('returns the section body without its own heading', () => { const section = extractSection(changelog, '0.24.0'); @@ -491,6 +509,16 @@ describe('extractSection', () => { assert.match(extractSection(plain, '0.24.0'), /^### Fixes/); }); + it('normalizes CRLF line endings in the extracted section', () => { + const crlf = + '# 0.24.0 (2026-08-01)\r\n\r\n### Fixes\r\n\r\n- entry\r\n'; + + assert.equal( + extractSection(crlf, '0.24.0'), + '### Fixes\n\n- entry' + ); + }); + it('does not match a different patch of the same minor', () => { assert.equal(extractSection(changelog, '0.24.1'), null); }); @@ -509,6 +537,213 @@ describe('extractSection', () => { }); }); +describe('extractPublicSection', () => { + it('strips the generated internal block from a mixed release', () => { + assert.equal( + extractPublicSection(changelog, '0.24.0'), + '### Features\n\n- **playback** — Up Next rail.' + ); + }); + + it('keeps a public-only release unchanged', () => { + const publicOnly = '# 0.24.0 (2026-08-01)\n\n### Fixes\n\n- Fixed it.'; + + assert.equal( + extractPublicSection(publicOnly, '0.24.0'), + '### Fixes\n\n- Fixed it.' + ); + }); + + it('returns an empty string for an internal-only release', () => { + assert.equal(extractPublicSection(internalOnlyChangelog, '0.24.0'), ''); + }); + + it('preserves unrelated details blocks', () => { + const release = [ + '# 0.24.0 (2026-08-01)', + '', + '
', + 'Migration guide', + '', + 'Run the migration.', + '', + '
', + '', + '
', + 'Internal changes', + '', + '- **deps** — parser bump.', + '', + '
', + ].join('\n'); + + assert.equal( + extractPublicSection(release, '0.24.0'), + '
\nMigration guide\n\nRun the migration.\n\n
' + ); + }); + + it('preserves near-match internal summaries', () => { + const release = [ + '# 0.24.0 (2026-08-01)', + '', + '
', + 'Internal changes ', + '', + '- trailing space.', + '', + '
', + '', + '
', + 'internal changes', + '', + '- different case.', + '', + '
', + ].join('\n'); + + assert.equal( + extractPublicSection(release, '0.24.0'), + release.split('\n').slice(2).join('\n') + ); + }); +}); + +describe('extract-changelog-section CLI contracts', () => { + it('uses public extraction for authored tag-release text', () => { + const workflow = readFileSync( + new URL( + '../../.github/workflows/build-and-make.yaml', + import.meta.url + ), + 'utf8' + ); + + assert.match( + workflow, + /extract-changelog-section\.mjs --public "\$\{VERSION\}"/ + ); + }); + + it('parses the public flag', () => { + assert.deepEqual(parseExtractArguments(['--public', '0.24.0']), { + version: '0.24.0', + publicOnly: true, + }); + }); + + it('reports usage for invalid arguments', () => { + for (const args of [ + [], + ['0.24'], + ['--unknown', '0.24.0'], + ['--public', '--public', '0.24.0'], + ['0.24.0', 'extra'], + ]) { + assert.equal(parseExtractArguments(args), null); + const result = runExtractorCli(changelog, args); + + assert.equal(result.exitCode, 2); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /Usage:/); + } + }); + + it('allows an empty public body for an internal-only release', () => { + assert.deepEqual( + runExtractorCli(internalOnlyChangelog, ['--public', '0.24.0']), + { exitCode: 0, stdout: '', stderr: '' } + ); + }); + + it('validates process arguments before reading the changelog', () => { + const result = runExtractorProcess(['--public'], () => { + throw new Error('changelog should not be read'); + }); + + assert.equal(result.exitCode, 2); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /Usage:/); + }); + + it('keeps raw process output and errors compatible', () => { + assert.deepEqual( + runExtractorProcess(['0.24.0'], () => '# 0.24.0 (2026-08-01)'), + { + exitCode: 1, + stdout: '', + stderr: 'CHANGELOG.md section for 0.24.0 is empty.\n', + } + ); + assert.deepEqual( + runExtractorProcess(['9.9.9'], () => changelog), + { + exitCode: 1, + stdout: '', + stderr: [ + 'CHANGELOG.md has no section for 9.9.9.', + 'The release flow writes it before tagging:', + ' pnpm run release:notes:changelog', + ' node tools/release/build-release-notes.mjs --consume', + 'Commit the changelog, then re-tag.', + '', + ].join('\n'), + } + ); + assert.deepEqual( + runExtractorProcess(['0.24.0'], () => changelog), + { + exitCode: 0, + stdout: + '### Features\n\n- **playback** — Up Next rail.\n\n
\nInternal changes\n\n- **deps** — parser bump.\n\n
\n', + stderr: '', + } + ); + }); + + it('normalizes CRLF output and appends exactly one trailing LF', () => { + const crlf = + '# 0.24.0 (2026-08-01)\r\n\r\n### Fixes\r\n\r\n- entry\r\n'; + const expected = { + exitCode: 0, + stdout: '### Fixes\n\n- entry\n', + stderr: '', + }; + + assert.deepEqual(runExtractorCli(crlf, ['0.24.0']), expected); + assert.deepEqual( + runExtractorProcess(['0.24.0'], () => crlf), + expected + ); + assert.match(expected.stdout, /[^\n]\n$/); + assert.doesNotMatch(expected.stdout, /\n\n$/); + }); + + it('reports the detailed missing-version diagnostic in public mode', () => { + const expected = { + exitCode: 1, + stdout: '', + stderr: [ + 'CHANGELOG.md has no section for 9.9.9.', + 'The release flow writes it before tagging:', + ' pnpm run release:notes:changelog', + ' node tools/release/build-release-notes.mjs --consume', + 'Commit the changelog, then re-tag.', + '', + ].join('\n'), + }; + + assert.deepEqual( + runExtractorCli(changelog, ['--public', '9.9.9']), + expected + ); + assert.deepEqual( + runExtractorProcess(['--public', '9.9.9'], () => changelog), + expected + ); + }); +}); + describe('helpers', () => { it('formats dates and release slugs', () => { assert.equal(formatLongDate('2026-08-01'), 'August 1, 2026'); diff --git a/tools/skills/project.json b/tools/skills/project.json new file mode 100644 index 000000000..8b6468d6a --- /dev/null +++ b/tools/skills/project.json @@ -0,0 +1,28 @@ +{ + "name": "repository-skills", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "tools/skills", + "tags": ["scope:tools", "domain:skills", "type:tool"], + "targets": { + "test": { + "executor": "nx:run-commands", + "cache": true, + "inputs": [ + "{workspaceRoot}/tools/skills/validate-repository-skills.mjs", + "{workspaceRoot}/tools/skills/validate-repository-skills.test.mjs" + ], + "options": { + "command": "node --test tools/skills/validate-repository-skills.test.mjs", + "cwd": "{workspaceRoot}" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "node --check tools/skills/validate-repository-skills.mjs", + "cwd": "{workspaceRoot}" + } + } + } +} diff --git a/tools/skills/validate-repository-skills.mjs b/tools/skills/validate-repository-skills.mjs new file mode 100644 index 000000000..8f0b26b5f --- /dev/null +++ b/tools/skills/validate-repository-skills.mjs @@ -0,0 +1,287 @@ +import { access, readdir, readFile } from 'node:fs/promises'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SKILLS_ROOT = '.codex/skills'; +const MIRRORED_SKILLS = ['release-cut', 'release-notes']; +const PATH_PREFIXES = [ + 'apps/', + 'libs/', + 'docs/', + 'tools/', + '.changes/', + '.github/', +]; +const ROOT_PATHS = new Set([ + 'package.json', + 'pnpm-lock.yaml', + 'nx.json', + 'tsconfig.base.json', + 'eslint.config.mjs', + 'CHANGELOG.md', + 'AGENTS.md', + 'CLAUDE.md', +]); +const NON_LITERAL_PATH_CHARACTERS = /[*?[\]{}<>]/u; + +function compareNames(left, right) { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function unquoteScalar(value) { + if (value.length < 2) return value; + + const firstCharacter = value[0]; + const lastCharacter = value.at(-1); + const hasMatchingQuotes = + (firstCharacter === '"' || firstCharacter === "'") && + lastCharacter === firstCharacter; + + return hasMatchingQuotes ? value.slice(1, -1) : value; +} + +function parseFrontmatter(markdown) { + const lines = markdown.split(/\r?\n/u); + if (lines[0] !== '---') { + return { fields: {}, continuedFields: new Set() }; + } + + const closingDelimiter = lines.indexOf('---', 1); + if (closingDelimiter === -1) { + return { fields: {}, continuedFields: new Set() }; + } + + const fields = {}; + const continuedFields = new Set(); + let activeField; + + for (const line of lines.slice(1, closingDelimiter)) { + if (line.trim() === '') continue; + + if (/^[ \t]/u.test(line)) { + if (activeField !== undefined) { + continuedFields.add(activeField); + } + continue; + } + + const separator = line.indexOf(':'); + if (separator <= 0) { + activeField = undefined; + continue; + } + + const key = line.slice(0, separator).trim(); + const value = line.slice(separator + 1).trim(); + fields[key] = unquoteScalar(value); + activeField = key; + } + + return { fields, continuedFields }; +} + +function countWords(markdown) { + const trimmedMarkdown = markdown.trim(); + return trimmedMarkdown === '' + ? 0 + : trimmedMarkdown.split(/\s+/u).length; +} + +function findLiteralRepositoryPaths(markdown) { + const paths = new Set(); + + for (const match of markdown.matchAll(/`([^`\r\n]+)`/gu)) { + const token = match[1]; + const isRepositoryPath = + ROOT_PATHS.has(token) || + PATH_PREFIXES.some((prefix) => token.startsWith(prefix)); + + if (isRepositoryPath && !NON_LITERAL_PATH_CHARACTERS.test(token)) { + paths.add(token); + } + } + + return [...paths].sort(compareNames); +} + +async function pathExists(path) { + try { + await access(path); + return true; + } catch (error) { + if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') { + return false; + } + throw error; + } +} + +function isPathWithinRoot(rootDir, absolutePath) { + const relativePath = relative(resolve(rootDir), absolutePath); + return ( + relativePath === '' || + (!isAbsolute(relativePath) && + relativePath !== '..' && + !relativePath.startsWith(`..${sep}`)) + ); +} + +async function readFileIfPresent(path) { + try { + return await readFile(path); + } catch (error) { + if (error?.code === 'ENOENT') return undefined; + throw error; + } +} + +async function validateSkill({ rootDir, directoryName }) { + const relativePath = `${SKILLS_ROOT}/${directoryName}/SKILL.md`; + const markdown = await readFile(resolve(rootDir, relativePath), 'utf8'); + const { fields: frontmatter, continuedFields } = + parseFrontmatter(markdown); + const diagnostics = []; + + if (frontmatter.name !== directoryName) { + diagnostics.push( + `${relativePath}: frontmatter name "${frontmatter.name ?? ''}" must match directory "${directoryName}"` + ); + } + + if ( + typeof frontmatter.description !== 'string' || + frontmatter.description === '' || + continuedFields.has('description') + ) { + diagnostics.push( + `${relativePath}: description must be a one-line frontmatter value` + ); + } else { + if (!frontmatter.description.startsWith('Use when')) { + diagnostics.push( + `${relativePath}: description must start with "Use when"` + ); + } + if (frontmatter.description.length > 500) { + diagnostics.push( + `${relativePath}: description must be at most 500 characters (received ${frontmatter.description.length})` + ); + } + } + + const wordCount = countWords(markdown); + if (wordCount > 500) { + diagnostics.push( + `${relativePath}: skill must be at most 500 words (received ${wordCount})` + ); + } + + for (const referencedPath of findLiteralRepositoryPaths(markdown)) { + const absolutePath = resolve(rootDir, referencedPath); + if (!isPathWithinRoot(rootDir, absolutePath)) { + diagnostics.push( + `${relativePath}: referenced path escapes repository root: ${referencedPath}` + ); + continue; + } + if (!(await pathExists(absolutePath))) { + diagnostics.push( + `${relativePath}: referenced path does not exist: ${referencedPath}` + ); + } + } + + return diagnostics; +} + +async function validateReleaseMirrors(rootDir) { + const diagnostics = []; + + for (const skillName of MIRRORED_SKILLS) { + const codexPath = `.codex/skills/${skillName}/SKILL.md`; + const claudePath = `.claude/skills/${skillName}/SKILL.md`; + const [codexContents, claudeContents] = await Promise.all([ + readFileIfPresent(resolve(rootDir, codexPath)), + readFileIfPresent(resolve(rootDir, claudePath)), + ]); + + if (codexContents === undefined) { + diagnostics.push( + `${codexPath}: required release skill mirror is missing` + ); + } + if (claudeContents === undefined) { + diagnostics.push( + `${claudePath}: required release skill mirror is missing` + ); + } + if ( + codexContents !== undefined && + claudeContents !== undefined && + !codexContents.equals(claudeContents) + ) { + diagnostics.push(`${codexPath}: differs from ${claudePath}`); + } + } + + return diagnostics; +} + +export async function validateRepositorySkills({ rootDir }) { + const directoryEntries = (await readdir(resolve(rootDir, SKILLS_ROOT), { + withFileTypes: true, + })) + .filter((entry) => entry.isDirectory()) + .sort((left, right) => compareNames(left.name, right.name)); + const skillDirectories = []; + const diagnostics = []; + + for (const entry of directoryEntries) { + if ( + await pathExists( + resolve(rootDir, SKILLS_ROOT, entry.name, 'SKILL.md') + ) + ) { + skillDirectories.push(entry.name); + } + } + + for (const directoryName of skillDirectories) { + diagnostics.push( + ...(await validateSkill({ rootDir, directoryName })) + ); + } + + diagnostics.push(...(await validateReleaseMirrors(rootDir))); + + return { + checkedSkills: skillDirectories.length, + diagnostics, + }; +} + +async function runCli() { + const { checkedSkills, diagnostics } = await validateRepositorySkills({ + rootDir: process.cwd(), + }); + + if (diagnostics.length > 0) { + for (const diagnostic of diagnostics) { + console.error(diagnostic); + } + process.exitCode = 1; + return; + } + + console.log(`Validated ${checkedSkills} repository skills.`); +} + +const isCli = + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isCli) { + await runCli(); +} diff --git a/tools/skills/validate-repository-skills.test.mjs b/tools/skills/validate-repository-skills.test.mjs new file mode 100644 index 000000000..ab5eb1ee4 --- /dev/null +++ b/tools/skills/validate-repository-skills.test.mjs @@ -0,0 +1,301 @@ +import assert from 'node:assert/strict'; +import { + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import test from 'node:test'; + +import { validateRepositorySkills } from './validate-repository-skills.mjs'; + +const validReleaseSkills = { + 'release-cut': `--- +name: release-cut +description: Use when preparing a repository release. +--- + +# Release Cut +`, + 'release-notes': `--- +name: release-notes +description: Use when deciding whether a change needs release notes. +--- + +# Release Notes +`, +}; + +async function writeFixtureFile(rootDir, relativePath, contents) { + const absolutePath = join(rootDir, relativePath); + await mkdir(dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, contents); +} + +async function createRepository( + t, + { + directoryName = 'example-skill', + name = directoryName, + description = + '"Use when deciding whether type: internal applies to a change."', + body = [ + '# Example Skill', + '', + 'Inspect `package.json` and `tools/existing.mjs`.', + 'Examples such as `apps/*-e2e` and `libs//feature` are not literal paths.', + '', + ].join('\n'), + releaseMirrorTransform = (contents) => contents, + } = {} +) { + const rootDir = await mkdtemp(join(tmpdir(), 'repository-skills-')); + t.after(() => rm(rootDir, { recursive: true, force: true })); + + await writeFixtureFile(rootDir, 'package.json', '{}\n'); + await writeFixtureFile(rootDir, 'tools/existing.mjs', 'export {};\n'); + await writeFixtureFile( + rootDir, + '.codex/skills/not-a-skill/README.md', + 'No SKILL.md lives here.\n' + ); + await writeFixtureFile( + rootDir, + `.codex/skills/${directoryName}/SKILL.md`, + `--- +name: ${name} +description: ${description} +--- + +${body}` + ); + await writeFixtureFile( + rootDir, + '.codex/skills/single-quoted-skill/SKILL.md', + `--- +name: single-quoted-skill +description: 'Use when deciding whether type: internal applies to a release.' +--- + +# Single-Quoted Skill +` + ); + + for (const [skillName, contents] of Object.entries(validReleaseSkills)) { + await writeFixtureFile( + rootDir, + `.codex/skills/${skillName}/SKILL.md`, + contents + ); + await writeFixtureFile( + rootDir, + `.claude/skills/${skillName}/SKILL.md`, + releaseMirrorTransform(contents, skillName) + ); + } + + return rootDir; +} + +test('accepts single- and double-quoted colon-space descriptions and skips path examples', async (t) => { + const rootDir = await createRepository(t); + + const result = await validateRepositorySkills({ rootDir }); + + assert.deepEqual(result, { + checkedSkills: 4, + diagnostics: [], + }); +}); + +test('reports a frontmatter name that differs from its directory', async (t) => { + const rootDir = await createRepository(t, { name: 'different-name' }); + + const { diagnostics } = await validateRepositorySkills({ rootDir }); + + assert.deepEqual(diagnostics, [ + '.codex/skills/example-skill/SKILL.md: frontmatter name "different-name" must match directory "example-skill"', + ]); +}); + +test('reports a description that does not begin with Use when', async (t) => { + const rootDir = await createRepository(t, { + description: 'Repository-specific implementation guidance.', + }); + + const { diagnostics } = await validateRepositorySkills({ rootDir }); + + assert.deepEqual(diagnostics, [ + '.codex/skills/example-skill/SKILL.md: description must start with "Use when"', + ]); +}); + +test('reports a description longer than 500 characters', async (t) => { + const description = `Use when ${'x'.repeat(493)}`; + assert.equal(description.length, 502); + const rootDir = await createRepository(t, { description }); + + const { diagnostics } = await validateRepositorySkills({ rootDir }); + + assert.deepEqual(diagnostics, [ + '.codex/skills/example-skill/SKILL.md: description must be at most 500 characters (received 502)', + ]); +}); + +test('rejects an indented plain-scalar description continuation', async (t) => { + const rootDir = await createRepository(t, { + description: `Use when editing a skill.\n ${'x'.repeat(501)}`, + }); + + const { diagnostics } = await validateRepositorySkills({ rootDir }); + + assert.deepEqual(diagnostics, [ + '.codex/skills/example-skill/SKILL.md: description must be a one-line frontmatter value', + ]); +}); + +test('rejects a blank-separated plain-scalar description continuation', async (t) => { + const rootDir = await createRepository(t, { + description: `Use when editing a skill.\n\n ${'x'.repeat(501)}`, + }); + + const { diagnostics } = await validateRepositorySkills({ rootDir }); + + assert.deepEqual(diagnostics, [ + '.codex/skills/example-skill/SKILL.md: description must be a one-line frontmatter value', + ]); +}); + +test('allows a blank frontmatter separator without treating it as a continuation', async (t) => { + const rootDir = await createRepository(t, { + description: 'Use when editing a skill.\n ', + }); + + const { diagnostics } = await validateRepositorySkills({ rootDir }); + + assert.deepEqual(diagnostics, []); +}); + +test('reports a complete skill longer than 500 words', async (t) => { + const rootDir = await createRepository(t, { + body: `# Example Skill\n\n${Array.from({ length: 501 }, () => 'word').join(' ')}`, + }); + + const { diagnostics } = await validateRepositorySkills({ rootDir }); + + assert.equal(diagnostics.length, 1); + assert.match( + diagnostics[0], + /^\.codex\/skills\/example-skill\/SKILL\.md: skill must be at most 500 words \(received \d+\)$/ + ); +}); + +test('reports a backticked literal repository path that does not exist', async (t) => { + const rootDir = await createRepository(t, { + body: '# Example Skill\n\nInspect `docs/missing-file.md`.', + }); + + const { diagnostics } = await validateRepositorySkills({ rootDir }); + + assert.deepEqual(diagnostics, [ + '.codex/skills/example-skill/SKILL.md: referenced path does not exist: docs/missing-file.md', + ]); +}); + +test('rejects a literal path that escapes the repository even when it exists', async (t) => { + const rootDir = await createRepository(t); + const outsideName = `${basename(rootDir)}-outside.md`; + const outsidePath = join(dirname(rootDir), outsideName); + const referencedPath = `docs/../../${outsideName}`; + t.after(() => rm(outsidePath, { force: true })); + await writeFile(outsidePath, 'outside\n'); + await writeFixtureFile( + rootDir, + '.codex/skills/example-skill/SKILL.md', + `--- +name: example-skill +description: Use when validating a literal path. +--- + +Inspect \`${referencedPath}\`. +` + ); + + const { diagnostics } = await validateRepositorySkills({ rootDir }); + + assert.deepEqual(diagnostics, [ + `.codex/skills/example-skill/SKILL.md: referenced path escapes repository root: ${referencedPath}`, + ]); +}); + +test( + 'propagates non-missing path access failures', + { skip: process.platform === 'win32' }, + async (t) => { + const rootDir = await createRepository(t, { + body: '# Example Skill\n\nInspect `docs/loop/file.md`.', + }); + await mkdir(join(rootDir, 'docs')); + await symlink('loop', join(rootDir, 'docs/loop')); + + await assert.rejects( + validateRepositorySkills({ rootDir }), + (error) => { + assert.equal(error.code, 'ELOOP'); + return true; + } + ); + } +); + +test('reports byte-different release skill mirrors', async (t) => { + const rootDir = await createRepository(t, { + releaseMirrorTransform: (contents, skillName) => + skillName === 'release-notes' ? `${contents}\n` : contents, + }); + + const { diagnostics } = await validateRepositorySkills({ rootDir }); + + assert.deepEqual(diagnostics, [ + '.codex/skills/release-notes/SKILL.md: differs from .claude/skills/release-notes/SKILL.md', + ]); +}); + +test('reports a missing release skill mirror without stopping validation', async (t) => { + const rootDir = await createRepository(t, { + description: 'Implementation guidance.', + }); + await rm( + join(rootDir, '.claude/skills/release-cut/SKILL.md') + ); + + const { diagnostics } = await validateRepositorySkills({ rootDir }); + + assert.deepEqual(diagnostics, [ + '.codex/skills/example-skill/SKILL.md: description must start with "Use when"', + '.claude/skills/release-cut/SKILL.md: required release skill mirror is missing', + ]); +}); + +test('returns every diagnostic in deterministic order', async (t) => { + const rootDir = await createRepository(t, { + name: 'wrong-name', + description: 'Implementation guidance.', + body: 'Inspect `docs/z-missing.md` and `docs/a-missing.md`.', + releaseMirrorTransform: (contents, skillName) => + skillName === 'release-notes' ? `${contents}\n` : contents, + }); + + const { diagnostics } = await validateRepositorySkills({ rootDir }); + + assert.deepEqual(diagnostics, [ + '.codex/skills/example-skill/SKILL.md: frontmatter name "wrong-name" must match directory "example-skill"', + '.codex/skills/example-skill/SKILL.md: description must start with "Use when"', + '.codex/skills/example-skill/SKILL.md: referenced path does not exist: docs/a-missing.md', + '.codex/skills/example-skill/SKILL.md: referenced path does not exist: docs/z-missing.md', + '.codex/skills/release-notes/SKILL.md: differs from .claude/skills/release-notes/SKILL.md', + ]); +});