mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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
This commit is contained in:
1 parent
99d167993d
commit
2ac0de752f
68 files changed
+7524
-793
No files matched your search
+9
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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<version>` 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<version>` 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.
|
||||
@@ -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/<area>-<slug>.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/<area>-<short-slug>.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.
|
||||
@@ -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 <project>
|
||||
pnpm nx test <project>
|
||||
pnpm nx show project <name>
|
||||
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/<project>/**/*.ts"`, then compare coverage with
|
||||
`find apps/<project> -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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<version>` 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<version>` 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.
|
||||
@@ -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/<area>-<slug>.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/<area>-<short-slug>.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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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}"
|
||||
|
||||
@@ -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 -- <why> */`; 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/<project>/**/*.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 <project> -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 `<area>-<short-slug>.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.
|
||||
|
||||
<!-- nx configuration start-->
|
||||
<!-- Leave the start & end comments to automatically receive updates. -->
|
||||
|
||||
@@ -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 `<area>-<short-slug>.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.
|
||||
|
||||
@@ -321,4 +321,3 @@ type ActiveXtreamRequest = {
|
||||
};
|
||||
|
||||
const activeXtreamRequests = new Map<string, ActiveXtreamRequest>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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<AppDatabase> {
|
||||
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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
if (this.useRuntimePersistence) {
|
||||
await this.runtimeBridge.clearPlaybackPositionOrThrow(
|
||||
playlistId,
|
||||
contentXtreamId,
|
||||
contentType
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dataSource.clearPlaybackPosition(
|
||||
playlistId,
|
||||
contentXtreamId,
|
||||
contentType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const providePortalPlaybackPositions = () => [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <name>
|
||||
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/<project>/**/*.ts"
|
||||
find apps/<project> -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
`<details><summary>Internal changes</summary>...</details>` 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 `<details>` 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.
|
||||
@@ -6,6 +6,10 @@ export interface PortalPlaybackPositions {
|
||||
playlistId: string,
|
||||
data: PlaybackPositionData
|
||||
): Promise<void>;
|
||||
savePlaybackPositionOrThrow(
|
||||
playlistId: string,
|
||||
data: PlaybackPositionData
|
||||
): Promise<void>;
|
||||
getPlaybackPosition(
|
||||
playlistId: string,
|
||||
contentXtreamId: number,
|
||||
@@ -21,6 +25,11 @@ export interface PortalPlaybackPositions {
|
||||
contentXtreamId: number,
|
||||
contentType: 'vod' | 'episode'
|
||||
): Promise<void>;
|
||||
clearPlaybackPositionOrThrow(
|
||||
playlistId: string,
|
||||
contentXtreamId: number,
|
||||
contentType: 'vod' | 'episode'
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export const PORTAL_PLAYBACK_POSITIONS =
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<VodSeriesSeasonVm>,
|
||||
fallbackPoster?: string
|
||||
options: MapVodSeriesEpisodesOptions
|
||||
): Record<string, XtreamSerieEpisode[]> {
|
||||
const mapped: Record<string, XtreamSerieEpisode[]> = {};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+32
-10
@@ -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 () => {
|
||||
|
||||
+2
-1
@@ -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'
|
||||
|
||||
+2
-2
@@ -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))
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown> & {
|
||||
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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+573
@@ -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> = {}
|
||||
): 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<number, PlaybackPositionData>([
|
||||
[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<number, PlaybackPositionData>([
|
||||
[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<number, PlaybackPositionData>([
|
||||
[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);
|
||||
});
|
||||
});
|
||||
+203
@@ -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<number, PlaybackPositionData>;
|
||||
legacyPositionByTrackingId: Map<number, PlaybackPositionData>;
|
||||
}
|
||||
|
||||
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<string, readonly XtreamSerieEpisode[]>
|
||||
>;
|
||||
seriesPositions: readonly PlaybackPositionData[];
|
||||
}): ReconciledStalkerSeriesPositions {
|
||||
const positionsByTrackingId = new Map<number, PlaybackPositionData>();
|
||||
const legacyPositionByTrackingId = new Map<
|
||||
number,
|
||||
PlaybackPositionData
|
||||
>();
|
||||
const indexedPositions = new Map<number, PlaybackPositionData>();
|
||||
|
||||
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<boolean> {
|
||||
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<PortalPlaybackPositions, 'clearPlaybackPosition'>;
|
||||
playlistId: string;
|
||||
position: PlaybackPositionData;
|
||||
legacyPosition?: PlaybackPositionData;
|
||||
}): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
+1
-1
@@ -201,7 +201,7 @@
|
||||
(episodeClicked)="onEpisodeClicked($event)"
|
||||
(episodeDownloadRequested)="downloadEpisode($event)"
|
||||
(playbackToggleRequested)="
|
||||
handlePlaybackToggleRequested($event)
|
||||
handlePlaybackToggleRequestedFromUi($event)
|
||||
"
|
||||
#seasonContainer
|
||||
/>
|
||||
|
||||
+457
-37
@@ -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<number, PlaybackPositionData>
|
||||
>(new Map());
|
||||
private readonly rawSeriesPositions = signal<
|
||||
readonly PlaybackPositionData[]
|
||||
>([]);
|
||||
private readonly legacyPositionByTrackingId = signal<
|
||||
Map<number, PlaybackPositionData>
|
||||
>(new Map());
|
||||
private activeSeriesPositionContext: SeriesPositionContext | null = null;
|
||||
private seriesPositionContextGeneration = 0;
|
||||
private readonly seriesPositionMutationQueues = new Map<
|
||||
string,
|
||||
Promise<void>
|
||||
>();
|
||||
private readonly pendingSeriesPositionLoads = new Map<
|
||||
SeriesPositionContext,
|
||||
Set<number>
|
||||
>();
|
||||
private readonly seriesPositionReloadKeys = new Set<string>();
|
||||
private seriesPositionsLoadGeneration = 0;
|
||||
private lastSaveTime = 0;
|
||||
private unsubscribePositionUpdates: (() => void) | null = null;
|
||||
readonly openingEpisodeId = signal<number | null>(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<Record<string, XtreamSerieEpisode[]>>(
|
||||
() => {
|
||||
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<void> {
|
||||
@@ -975,19 +1094,320 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private async loadSeriesPositions(
|
||||
context: SeriesPositionContext
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
const positions =
|
||||
await this.playbackPositions.getSeriesPlaybackPositions(
|
||||
playlistId,
|
||||
seriesXtreamId
|
||||
);
|
||||
const positionsMap = new Map<number, PlaybackPositionData>();
|
||||
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<number>();
|
||||
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<void>
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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(
|
||||
|
||||
+1199
File diff suppressed because it is too large.
Load diff
@@ -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(
|
||||
|
||||
@@ -65,6 +65,27 @@ export class PlaybackPositionRuntimeBridgeService {
|
||||
await this.bridge?.dbSavePlaybackPosition?.(playlistId, data);
|
||||
}
|
||||
|
||||
async savePlaybackPositionOrThrow(
|
||||
playlistId: string,
|
||||
data: PlaybackPositionData
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<SqlTraceStatementType>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -24,6 +24,11 @@ const workspaceRoot = path.resolve(
|
||||
'../..'
|
||||
);
|
||||
|
||||
const INTERNAL_DETAILS_BLOCK =
|
||||
/(?:^|\n\n)<details>\n<summary>Internal changes<\/summary>\n\n[\s\S]*?\n\n<\/details>(?=\n\n|$)/g;
|
||||
const CLI_USAGE =
|
||||
'Usage: extract-changelog-section.mjs [--public] <version>';
|
||||
|
||||
/**
|
||||
* @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 <version> (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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.',
|
||||
'',
|
||||
'<!-- next-release -->',
|
||||
'',
|
||||
'# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-08-01)',
|
||||
'',
|
||||
'### Features',
|
||||
'',
|
||||
'- **playback** — Up Next rail.',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>Internal changes</summary>',
|
||||
'',
|
||||
'- **deps** — parser bump.',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'# [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)',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>Internal changes</summary>',
|
||||
'',
|
||||
'- **deps** — parser bump.',
|
||||
'',
|
||||
'</details>',
|
||||
].join('\n');
|
||||
|
||||
describe('extractSection', () => {
|
||||
const changelog = [
|
||||
'# Changelog',
|
||||
'',
|
||||
'Intro paragraph with a pointer.',
|
||||
'',
|
||||
'<!-- next-release -->',
|
||||
'',
|
||||
'# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-08-01)',
|
||||
'',
|
||||
'### Features',
|
||||
'',
|
||||
'- **playback** — Up Next rail.',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>Internal changes</summary>',
|
||||
'',
|
||||
'- **deps** — parser bump.',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'# [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)',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>Migration guide</summary>',
|
||||
'',
|
||||
'Run the migration.',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>Internal changes</summary>',
|
||||
'',
|
||||
'- **deps** — parser bump.',
|
||||
'',
|
||||
'</details>',
|
||||
].join('\n');
|
||||
|
||||
assert.equal(
|
||||
extractPublicSection(release, '0.24.0'),
|
||||
'<details>\n<summary>Migration guide</summary>\n\nRun the migration.\n\n</details>'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves near-match internal summaries', () => {
|
||||
const release = [
|
||||
'# 0.24.0 (2026-08-01)',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>Internal changes </summary>',
|
||||
'',
|
||||
'- trailing space.',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>internal changes</summary>',
|
||||
'',
|
||||
'- different case.',
|
||||
'',
|
||||
'</details>',
|
||||
].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<details>\n<summary>Internal changes</summary>\n\n- **deps** — parser bump.\n\n</details>\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');
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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/<domain>/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',
|
||||
]);
|
||||
});
|
||||
Reference in new issue
Block a user