Commit Graph
681 Commits
Author SHA1 Message Date
4grayandClaude Opus 5 72f8cebd2e fix(e2e): reap data directories abandoned by earlier runs (#1296)
`removeDataDir` tolerates a locked directory rather than failing the run, but
then abandons it, and nothing collects it on our behalf: Windows never clears
%TEMP% on process exit, and the Unix equivalents only run on a schedule. Every
teardown that lost that race leaked a database and user-data tree on developer
machines and long-lived runners, invisibly, while CI stayed green.

Sweeps leftover `iptvnator-electron-e2e-*` directories once per run, before the
first one is created. Ownership is settled by pid rather than age: each run
records its pid and the sweep asks the OS via `process.kill(pid, 0)`.

- A live owner is kept, so a concurrent suite is never collected — this repo is
  routinely checked out into several worktrees at once. Age cannot answer this:
  writes land under `databases/` and `user-data/`, which never refreshes the
  root's mtime, so a run paused in a debugger looks arbitrarily old.
- A dead owner is collected immediately.
- An undeterminable owner (missing, empty or malformed marker) falls back to a
  24h cutoff. The marker is published via rename so a half-written file cannot
  bypass that guard.
- A live-looking owner past a week is collected anyway, since the OS recycles
  pids and a stranger inheriting one would otherwise pin the directory forever.

Covered by a 10-test spec running on Linux, macOS and Windows, since
`process.kill(pid, 0)` semantics are platform-specific. Each behaviour was
verified to fail against the preceding implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 09:41:24 +02:00
4gray a2fafcfc08 test(performance): add end-to-end Xtream benchmark harness (#1300)
* docs(performance): plan Xtream benchmark

* feat(xtream-mock-server): add deterministic 100k fixture

* style(xtream-mock-server): apply repository formatting

* fix(xtream-mock-server): harden performance fixture data

* feat(xtream-mock-server): add performance control plane

* docs(performance): correct Xtream capture plan

* fix(xtream-mock-server): harden performance controls

* fix(xtream-mock-server): harden control lifecycle

* feat(performance): add Xtream preload markers

* feat(performance): trace Xtream main phases

* feat(performance): mark Xtream store publications

* feat(performance): trace Xtream database phases

* feat(performance): trace Xtream delete cancellation

* feat(performance): capture Xtream phase attribution

* feat(performance): mark Sources Xtream refresh

* test(performance): define Xtream benchmark evidence contracts

* test(performance): add Xtream benchmark runner

* test(performance): surface failure evidence writes

* test(performance): align database read clock

* test(performance): preserve capture failure contracts
2026-07-28 08:08:07 +02:00
4gray bfad82c26c fix(settings): stop settings silently reverting on restart (#1272)
Settings live in the renderer's IndexedDB, and two failure modes made them look
saved while nothing reached disk.

A second app instance sharing the same userData directory cannot take the
Chromium storage lock, so its renderer reads defaults and every write is
dropped. The app now holds a single-instance lock and focuses the running window
instead of starting a rival copy. The lock is requested after the userData
override so E2E runs with their own data dir keep independent locks, and after
Squirrel event handling. IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1 opts out for local
CDP debugging.

updateSettings() patches in-memory state before persisting and the submit path
had no rejection handler, so a failed write produced an unhandled rejection and
no user-visible feedback. SettingsStore now records which half of the round trip
failed, and the settings page surfaces it through a dismissible error snackbar;
the dialog stays open on failure so the save can be retried.

Two follow-ups from review, both wider than the report:

- a second launch now re-creates the main window when the lock owner has none
  left, so closing the last window on macOS no longer leaves a second launch
  quitting silently with nothing on screen
- App.onMainWindowCreated() re-runs window-owned bindings for every rebuilt
  window, so the downloads broadcaster stops holding a destroyed window. This
  also fixes the same bug on the pre-existing dock `activate` path.

Closes #1156
Closes #102
2026-07-27 23:14:30 +02:00
4grayandClaude Opus 5 0334296f15 fix(electron-backend): make test suite and lint host-agnostic on Windows checkouts (#1176)
* fix(electron-backend): make test suite and lint host-agnostic across Windows/Linux checkouts

Windows checkouts (core.autocrlf=true) had 13 pre-existing jest failures
and 5 Windows-only lint errors in electron-backend while Linux CI was
green:

- embedded-mpv-native-source.spec: normalize CRLF after readFileSync so
  multi-line source assertions match on autocrlf checkouts
- worker-runtime-paths.spec: build expected candidate paths with
  path.join instead of hardcoded POSIX strings
- external-player-launch-context: join darwin-only paths (.app bundle
  executables, Homebrew Caskroom) with path.posix.join so simulated
  darwin platforms resolve correctly on win32 hosts (no-op on macOS)
- app.spec: build packaged-navigation fixtures with path.resolve +
  pathToFileURL; file:///tmp/... is not a valid win32 file URL
- lint target: quote the eslint glob. The unquoted ** was expanded by
  the POSIX shell on Linux (shallow match), so CI linted only a subset
  of files while Windows passed the literal pattern to ESLint and
  linted the full tree - the hosts checked different file sets
- fix the 5 errors full-tree linting surfaces: prefer-const in
  epg-worker.service and database.worker-connection, no-unsafe-finally
  in external-player-session-registry and embedded-mpv-native.service
  (rewritten as catch-swallow with identical semantics, pinned by new
  regression tests), intentional no-control-regex in the recording
  filename sanitizer; drop stale unused disable directives
- document the quoted-glob convention in CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: mirror the quoted-lint-glob convention into AGENTS.md

AGENTS.md already mirrors the neighbouring max-lines/baseline paragraph from
CLAUDE.md, and it requires coding conventions to stay in sync between the two
files. The quoted-glob rule landed only in CLAUDE.md, so agents bootstrapping
from AGENTS.md could reintroduce a host-dependent lint target.

Also corrects the wording in both copies: the shallow expansion happens on
macOS as well as Linux — /bin/sh has no globstar on either — and the target
still exits 0 with a broken glob, which is why this went unnoticed. Adds the
file-count check that catches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 23:05:22 +02:00
4grayandClaude Opus 5 19b592badb fix(epg): make manual EPG mapping lookups actually fail soft (#1291)
The mapping handlers documented a fail-soft contract but only honored half
of it. Four of them returned the database operation's promise from inside
the `try` block without awaiting it, so the rejection escaped the `catch`:
the try block exits before the promise settles. A `getDatabase()` failure
was caught, but a SQLite error in the operation rejected the
`ipcMain.handle` promise, and the renderer call threw instead of receiving
`null` / `{success:false}` / `[]`.

Adding `await` in handleGetEpgMapping, handleSetEpgMapping,
handleDeleteEpgMapping and handleSearchEpgChannels closes the gap.
resolveChannelIds and handleGetEpgMappingsBatch already awaited correctly
and are unchanged.

This is pre-existing — the same shape predates the epg.events.ts split in
636545cb, which preserved semantics faithfully and inherited the bug.

Adds epg-mapping.service.spec.ts, the first coverage these handlers have
had: table-driven over all six functions against both failure modes, plus
the guard clauses and queryByResolvedChannelIds remapping. Verified to fail
on the old behavior — reverting the four awaits fails exactly the four
operation-rejection cases.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:15:50 +02:00
4gray 5932e71cb9 fix(electron-backend): process zero-delay database cancellation (#1295) 2026-07-27 21:59:20 +02:00
4grayandClaude Opus 5 d2fd27b535 test(performance): deflake the real-timer event-loop delay spec (#1293)
* test(performance): deflake the real-timer event-loop delay spec

The real-timer capture spec failed under full-suite parallelism because
`monitorEventLoopDelay()` records nothing on its first internal timer
tick - that tick only seeds the previous timestamp, so the first delay
sample lands on the second tick. Condition-based arming therefore needs
two event-loop turns inside its fixed 50ms wall-clock budget, and a
machine running 10 Jest workers stretches a single turn past 20ms. The
capture then degraded to a documented `event-loop-delay-arm-timeout`,
which is the intended graceful path, while the spec asserted the happy
path of that race and turned an environmental outcome into a red build.

Retry the real-runtime capture within a 5s budget instead. The real
`node:perf_hooks` runtime and the real 20ms block are kept, since the
fake harness returns a hard-coded histogram max and never measures
anything. Every attempt still asserts a contract: instrumentation never
breaks the wrapped work, and a null delay must carry a documented
arm/flush timeout rather than being silently null. Budget exhaustion
warns instead of failing, so load can no longer produce a false failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(performance): assert the real delay measurement unconditionally

Codex flagged that budget exhaustion still passed the test, so a
regression that made arming or flushing time out on every attempt would
have been reported as a warning rather than a failure - removing the only
assertion backed by Node's real histogram and a genuine event-loop block.

Drop the tolerant retry loop. The arming deadline is read through the
injectable `readMonotonicMs()`, so scaling only that clock leaves the
wait bounded by its other limit, the 50-poll ceiling, which is ~25x the
two event-loop turns arming actually needs. Everything else stays
production code: the real `monitorEventLoopDelay()` histogram, real
`setTimeout()` polling, and real epoch/CPU/ELU boundaries. The scaled
clock reaches nothing but the wait budgets, since its only other consumer
records phase events and this spec records none.

The test now always asserts a real measurement and hard-fails otherwise.
Verified by mutation: forcing arming to never arm fails it, and dropping
the deliberate block fails it at maxMs 3.8ms against the 10ms floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:33:08 +02:00
4grayandClaude Opus 5 518964c57f refactor(electron-backend): split the last four files over the max-lines cap (#1288)
Follow-up to #1278, which split four of the files the electron-backend lint
target had been silently skipping. Four were left over; this splits them, so
no file in the project sits above the 400-line cap outside the baseline.

None are added to tools/eslint/max-lines-baseline.mjs — the baseline only
shrinks. Shared setup moves to *.test-helpers.ts, the suffix
tsconfig.app.json already excludes, so the production build never sees jest
globals.

- remote-control.events.spec.ts 588 -> 188, plus remote-control-http.spec.ts.
  The mock registry moves to remote-control.test-helpers.ts; each spec keeps
  its own jest.mock() factories, which resolve the mock-prefixed exports.
- downloads.events.spec.ts 530 -> 182, plus downloads-actions.spec.ts. The
  jest.doMock setup is not hoisted, so the whole harness moves to
  downloads.test-helpers.ts behind setupDownloadsEventsHarness().
- http-server.spec.ts 448 -> 377, plus resolve-static-file-path.spec.ts. The
  resolveStaticFilePath cases were already self-contained.
- worker-performance-capture.spec.ts 408 -> 149, plus
  worker-performance-capture.resilience.spec.ts, matching the .concurrency
  and .histogram siblings.

Test bodies are unchanged; the helpers keep the same identifiers in scope so
the splits are a move, not a rewrite.

Verified with the glob quoted locally (that quoting is #1176's, not part of
this change): 245 files, 0 max-lines errors, and the only remaining lint
errors are the five #1176 fixes. Both tsconfig.app.json and
tsconfig.spec.json typecheck clean.

Note: worker-performance-capture.concurrency.spec.ts is flaky on master
independently of this change — it asserts a real 20ms event-loop block and
failed 4/4 clean-master runs here.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:21:24 +02:00
4gray 24f0dee6f0 test(performance): add formal M3U import benchmark (#1287)
* test(performance): add formal M3U import benchmark

* test(performance): harden formal capture validity

* test(performance): address benchmark review feedback
2026-07-27 10:34:22 +02:00
4grayandClaude Opus 5 26331676f9 fix(epg): await mapping queries so lookups fail soft (#1279)
The four EPG mapping handlers wrap their DB call in try/catch but return the
promise instead of awaiting it. An async function *adopts* a returned promise
rather than awaiting it, so the catch block only ever fired when getDatabase()
itself threw — a rejection from the underlying query escaped to the IPC caller
instead of returning the intended null / {success:false} / [] fallback.

Add the missing await to handleGetEpgMapping, handleSetEpgMapping,
handleDeleteEpgMapping and handleSearchEpgChannels, matching
handleGetEpgMappingsBatch and resolveChannelIds in the same module, which
already awaited and so already failed soft for both cases. This restores the
contract stated in the module's own doc comment: a mapping lookup must never
take down an EPG request.

The behavior predates the max-lines split in #1278, which carried it over
verbatim from epg.events.ts.

The new spec drives the real IPC handlers captured from ipcMain.handle, so it
asserts the contract that matters: the caller gets a fallback, not a rejected
promise. Reverting the four awaits fails exactly those four handlers and
leaves the already-correct batch handler green.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 08:43:52 +02:00
4gray e2300bea11 test(settings): split the settings spec along the facade seams (#1277)
settings.component.spec.ts was 1516 lines and the last settings file in the
max-lines baseline. The behaviour that moved into facades now has its own
specs, driven directly instead of through the rendered page.

- settings-app-update.facade.spec.ts: status polling/retry, bridge actions,
  release notes dialog, version messaging, dispose
- settings-epg.facade.spec.ts: refresh, clear flow, post-save re-fetch
- settings-playlist-reset.facade.spec.ts: summary, dialog, Electron progress,
  browser fallback, failure snackbar
- settings-backup.facade.spec.ts: desktop export, browser download fallback
- settings.component.spec.ts keeps the page shell, the facade lifecycle seam
  and runtime capabilities; settings.component.form.spec.ts takes hydration,
  section outputs, dashboard controls and submit
- settings-section-scroll.directive.spec.ts gives the directive its first spec
- shared TestBed fixtures live in settings/test-stubs/, kept out of both the
  app build (.stub.ts) and the coverage ratchet (test-stubs/)

105 settings tests, up from 94; every file is under the 400-line limit, so
settings.component.spec.ts leaves the baseline.
2026-07-27 08:31:14 +02:00
4gray e55d55b47f feat(mock-data): add shared screenshot-safe poster catalog (#1271)
Moves the fictional movie catalog into `libs/shared/marketing-fixtures` so the
Xtream and Stalker mocks describe the same titles, and adds 20 rendered posters
plus the shared fixture types behind them.

Supporting changes made while getting it green:

- `shared-marketing-fixtures` is classified Tier B in the coverage policy. Not
  Tier A: it is fictional fixture data, so a statement percentage over it means
  nothing, and a Tier A entry would pull it into the merged coverage map and the
  ratchet. Tier B still runs its spec in CI. `stalker-mock-server` needs no entry
  of its own — it is already Tier C and the Tier B/C runner falls back to
  `pnpm nx test <project>`, so its new `marketing-poster-url.spec.ts` runs.
- Two release-capture defects the catalog reorder introduced, both fixed in
  `tools/release/capture-app-driver.ts`:
  - VOD stream ids are `MARKETING_VOD_STREAM_ID_BASE + index` and the generator
    now lists the showcase movies first, so 62000-62002 became Black Harbor, The
    Paper Astronaut and Summer Static while the dashboard seeding still mapped
    those ids to the previous titles' backdrops.
  - the raw `tsx` spawn of the Xtream mock lacked `--tsconfig
    tsconfig.base.json`, so the mock could not resolve
    `@iptvnator/shared/marketing-fixtures` and the capture never started. Both
    mock projects' own serve targets already passed the flag.
2026-07-27 08:10:57 +02:00
4gray e1c4853a39 Merge pull request #1280 from 4gray/agent/perf-exact-process-memory
fix(perf): make process memory captures comparison-safe
2026-07-27 02:57:02 +02:00
4gray 2fda6cea07 fix(perf): reject incomplete renderer RSS samples 2026-07-27 02:27:57 +02:00
4gray 636545cbb7 refactor(electron-backend): split four files under the max-lines limit (#1278)
Split epg.events.ts (514), the embedded MPV frame-copy adapter (428) and two of its specs (547, 539) below the 400-line hard limit, and teach the baseline generator to skip files that already carry a justified file-wide eslint-disable max-lines.

The generated baseline list is unchanged: 128 entries before and after. No behavior change.
2026-07-27 02:16:02 +02:00
4gray 554eecfee0 fix(perf): finalize exact worker capture safely 2026-07-27 02:15:59 +02:00
4gray d3fdbaa2ef fix(perf): aggregate every worker isolate 2026-07-27 02:15:59 +02:00
4gray b7ddb5e90a chore(perf): add database worker heap probe transport 2026-07-27 02:15:59 +02:00
4gray 1fe6f30e64 chore(perf): prepare database post-GC capture 2026-07-27 02:15:59 +02:00
4gray 918c8f2ded fix(perf): scope renderer RSS to exact window 2026-07-27 02:15:59 +02:00
4gray 5aab81e2cb refactor(perf): expose serializable renderer RSS capture 2026-07-27 02:15:58 +02:00
4gray e27685dc5d fix(perf): model exact renderer RSS capture 2026-07-27 02:15:58 +02:00
4gray 2455165043 chore(electron): route the worker heap probe 2026-07-27 02:15:58 +02:00
4gray d1e268f1dd chore(electron): add perf-only worker heap probe 2026-07-27 02:15:58 +02:00
4gray 5533ac0fc1 fix(perf): preflight profile artifact capacity 2026-07-27 02:15:58 +02:00
4gray 75c45c9e91 Merge pull request #1275 from 4gray/agent/m3u-renderer-performance
test(perf): add request-scoped M3U benchmark profiling
2026-07-27 00:07:56 +02:00
4gray a3a8f6e90c fix(perf): optimize benchmark worker builds 2026-07-26 23:25:09 +02:00
4gray df7eee7e52 fix(perf): exclude profiler flush from cancel latency 2026-07-26 23:14:57 +02:00
4gray f9ea3070ee refactor(settings): split the settings page into per-section facades (#1274)
settings.component.ts had grown to 819 lines — past the CLAUDE.md target (<300)
and hard maximum, passing lint only because it sat in the max-lines baseline.

The behaviour moves into facades the template binds to directly, following the
precedent already in this folder: new app-update (218), form (197), epg (123),
embedded-mpv (74) and remote-control (37) facades, with playlist-reset extended
to 143 and settings-options to 200. The component is now a 259-line coordinator
holding capability flags, section nav, players() and the cross-facade flows.
settings.component.ts is removed from the max-lines baseline.

No behaviour change. One ordering detail: applyChangedSettings now applies
language/theme before kicking off the EPG re-fetch; changeTheme only touches DOM
theme sync and translate.use does not touch the form, so the two are
independent.
2026-07-26 22:57:16 +02:00
4gray 8bafd62932 fix(perf): benchmark the performance build 2026-07-26 22:49:36 +02:00
4gray da3b657277 fix(perf): make worker profiling request scoped 2026-07-26 22:37:52 +02:00
4gray 0579d253c4 fix(electron): fail closed on unknown performance completions 2026-07-26 22:37:52 +02:00
4gray f9e71a6d8d fix(electron): isolate refresh performance correlation 2026-07-26 22:37:52 +02:00
4gray e3ce60a35e chore(electron): add preload performance markers 2026-07-26 22:37:52 +02:00
4gray 2cc7d0acb1 fix(perf): declare renderer cache output path 2026-07-26 22:37:52 +02:00
4gray 28ed38906d test(perf): add production renderer benchmark build 2026-07-26 22:37:52 +02:00
4gray 08b868d6c1 test(electron): harden runtime boundary coverage (#1267)
Adds contract-focused regression coverage for the Electron HTTP server,
remote-control events, settings events, and managed download paths, and makes
Tier A coverage fail closed when instrumentation fails or a runtime-owning
production file disappears from a project or from the merged Istanbul report.

The old `coverage:ci` exited 0 despite a `Failed to collect coverage`
diagnostic: libs/m3u-state/src/lib/effects.ts was simply absent from the merged
map. All 30 Tier A reports are now required, the merged map covers 710 files,
and effects.ts is reported as 0/159 instead of silently disappearing.

Also fixes remote static-file path containment for encoded, malformed, NUL,
POSIX and Win32-style traversal inputs, with behavior-preserving testability
seams.

Statements 69.27% -> 69.54%; http-server.ts 0% -> 90.21%,
remote-control.events.ts 0% -> 96.55%, settings.events.ts 59.25% -> 96.29%.
2026-07-26 22:27:50 +02:00
4gray 1ab82b04a1 refactor(deps): drop uuid for a shared crypto-based id helper (#1266)
Supersedes #1252 and #872. uuid 14 is ESM-only, apps/web/jest.config.ts only
kept v9 working by mapping `^uuid$` at a `wrapper.mjs` that v14 no longer
ships, and the specifier also has to be synced in
libs/shared/m3u-utils/package.json or @nx/dependency-checks fails lint.

All four call sites only used `v4()`, so the dependency goes away instead.
`createRandomId()` prefers `crypto.randomUUID()` and falls back to building the
same v4 shape from `crypto.getRandomValues()` — that fallback is load-bearing,
because randomUUID is only exposed in secure contexts and the self-hosted PWA
is regularly served over plain http on a LAN address. getRandomValues stays
available there, and it is what uuid's own v4 used.

`@types/uuid` goes too; it only existed for the untyped v9 package.
2026-07-26 22:27:31 +02:00
4gray d5f5beab38 chore(deps): bump the npm minor/patch group across 43 packages (#1270)
Rebuilt from #1251 so the group could merge, on top of the transitive-CVE
overrides from #1258. Supersedes #1230 and #1251.

Carries axios 1.16.0 -> 1.18.1, closing seven runtime-scope advisories
including the proxy-credential leak on redirects, and sharp 0.34.5 -> 0.35.3
for the libvips CVEs.

`esModuleInterop` moves to tsconfig.base.json. artplayer 5.4.0 switched from a
Parcel build exposing `module.exports.default` to UMD assigning
`module.exports` directly; the flag was only set in apps/web, so every lib
compiled `import Artplayer from 'artplayer'` to `.default` and got undefined.
Production was never affected — esbuild resolves the ESM entry.

Two packages are deliberately held back, each for its own PR:

- epg-parser ^0.5.0 — grouped as a minor, but 0.x minors are breaking and this
  one reshapes the parse output (`channel.name` -> `displayName`, icons/urls
  become objects, `credits` becomes role-keyed, dates switch to ISO). Its only
  consumer is the uncovered web-backend `/parse-xml` endpoint.
- electron-builder ^26.15.3 — rewrote the snap target, and the resulting snap
  cannot start (`command.sh` execs a `desktop-init.sh` that never lands at the
  snap root under our core22 strict config). Its two required fixes go with it:
  the `engines` node floor from @electron/rebuild 4, and resolving upstream
  node-gyp instead of the dropped `@electron/node-gyp` fork.
2026-07-26 19:43:35 +02:00
4gray f147d4fe37 perf(m3u): stop cancelled refresh workers (#1268) 2026-07-26 09:25:51 +02:00
4gray c0e065da17 fix(window-controls): derive window-state pushes from events so controls reappear after fullscreen (#1178)
The custom minimize/maximize/close controls stayed hidden forever after
leaving HTML-element (video player) fullscreen on Windows: window state was
polled at event time, and isFullScreen() can still report the pre-transition
value while 'leave-full-screen' fires, leaving a stale push with no later
event to correct it. The same polling on the companion flag cleared
isMaximized during fullscreen transitions and stuck the maximize/restore
glyph on the wrong icon.

attachWindowStateEvents now seeds the state once at window creation and each
event patches only the flag it names, sending a copy per push. The
enter/leave-html-full-screen variants are wired too.

Regression coverage: app-window-state.spec.ts (9 cases, 6 of which fail
against the old implementation) and an Electron E2E case that toggles HTML
element fullscreen and asserts the controls come back.
2026-07-26 01:49:08 +02:00
4grayandClaude Opus 4.8 0b967d66d4 feat(tmdb): metadata cache panel with a clear button in settings (#1244)
* feat(tmdb): metadata cache panel with a clear button in settings

Adds "Metadata cache — N entries · X MB" with a Clear button to
Settings > Metadata (TMDB), next to the API key it belongs to.

Three things it is good for: dropping stale or wrong metadata so the next
open refetches it, seeing what the cache actually costs on disk, and
reclaiming rows that a lookup-key version bump has orphaned — a bump makes
rows unreachable, not deleted, so nothing else would ever collect them.

Sizing the cache is a full table scan (LENGTH() on TEXT counts characters,
so the SUM casts to BLOB to get bytes), which is why stats load lazily and
only once the TMDB section is the active one rather than on every settings
open. Clearing is always safe: enrichment refetches on demand, so the only
cost is the next few requests.

Works in both environments — the PWA has no bridge, so the service reports
and clears its session-scoped in-memory map instead.

i18n: 4 keys across all 19 locales via the tools/i18n workflow;
placeholder integrity verified. Contract fixtures updated for both the
preload bridge and the DB-worker payload shapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): make cache clearing durable and stop reporting failures as empty

Four review findings, all real:

- A metadata write already in flight when the user cleared would land
  afterwards and silently restore what they removed. Writes now carry the
  generation they started in; a write that outlives a clear is dropped
  (PWA) or undone (Electron).
- The PWA byte count used String.length, i.e. UTF-16 code units, so
  localized payloads under-reported and disagreed with the SQLite BLOB
  byte count. TextEncoder now measures actual bytes.
- A failed stats read returned a valid zero-entry result, so the panel
  claimed an empty cache and disabled Clear while rows were still there.
  getStats/clear now return null on failure and the panel says so instead
  of inventing state.
- No behavioural coverage existed for either side.

Tests: SQL ops (entry/byte reporting, empty table, missing row, delete
count) and the service (encoded bytes, clear count, and a write racing a
clear).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): make the cache clear precise and version skew visible

Review follow-ups on the cache panel:

- A write that was in flight when the user cleared used to trigger a
  second full-table clear once it landed, which also deleted anything
  written in between. clear() now waits for the writes issued before it
  and lets the single clear take them; later writes survive.
- An Electron shell without the maintenance ops fell through to the
  renderer map, which is always empty there — it reported an empty cache
  and disabled the Clear button while SQLite was full. Both operations
  now report unsupported instead.
- Component coverage for the panel (deferred scan, clear + re-read,
  failed clear, failed read) and Electron-path service coverage.
- The canonical IPC and settings sections of the enrichment doc, plus
  the matching CLAUDE.md lines, now list the maintenance ops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): drop Promise.allSettled from the cache clear

The web target compiles against lib es2018, so allSettled broke the
Windows frontend build (TS2550). The pending writes swallow their own
errors, so a plain Promise.all over neutralized promises does the job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): keep a synchronous bridge throw inside the cache write

Moving the write into a tracked promise dropped the try/catch that used
to cover the call itself, so a bridge that threw synchronously would
escape set(). Wrap it in an async IIFE, which turns that back into a
rejection the same handler swallows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): retry the cache size read when the section is reopened

The effect skipped the read once cacheError was set, so one transient
IPC failure left the panel showing "could not read the cache" for the
life of the settings page — and the only enabled control that could
shift it was the destructive Clear button. Gate on the stats signal
alone: reopening the section retries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): queue writes that start while the cache is being cleared

Awaiting the in-flight writes closed one side of the race and left the
other open: a set() that started during that wait dispatched its IPC
immediately, was absent from the snapshot, and could reach SQLite just
before the delete — so a row written after the user clicked Clear was
removed anyway.

clear() now holds its own promise for the whole operation and set() waits
on it, which puts such a write on the far side of the delete. Rows are
stamped when they are dispatched rather than when set() was called, since
a write may have waited. Covered by a test that fails without the guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(tmdb): add the release note for the cache panel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(tmdb): cover the cache panel with an Electron E2E

The panel drives IPC and SQLite, and nothing exercised that path end to
end. The new test seeds a row through the preload bridge — enrichment
itself needs a TMDB key that CI does not have — then opens the section,
asserts the reported size, clears, and reads the database back to confirm
the row is gone rather than merely hidden.

Verified both ways: dropping the DELETE from clearTmdbMetadata fails it.

Settings nav buttons gained a data-test-id so the section can be opened
without matching translated labels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:13:02 +02:00
4grayandClaude Opus 5 9885178f32 fix(stalker): refresh stale embedded-series snapshots from favorites and dashboard (#1253)
Favorites and recently-viewed rows store Stalker items as full JSON
snapshots, so a vclub-style embedded series[] episode list froze at the
moment the row was written: a series favorited when only episode 1 was out
kept showing one episode forever when opened from favorites, recents,
Continue Watching, or any dashboard rail.

New withStalkerSnapshotRefresh() store feature renders the stored snapshot
immediately and re-fetches the item from the portal in the background via a
title search (get_ordered_list&type=vod&search=..., matched by id, paginated
up to 5 pages, wildcard-category retry), patching fresh episodes and cmd into
the active selection. The patch is guarded on both the item id and the active
playlist id, since Stalker ids are only unique per portal.

Only the in-memory selection is patched — the stored snapshot row is
deliberately left alone, because every entry path into the detail view runs
this refresh and writing it back would add an uncontrolled background writer
to the whole-playlist read-modify-write that every favorite/recent mutation
performs.

Also fixes the stalker-mock-server embedded-series scenario, which generated
series[] as objects the app's vclub adapters filter out instead of the
episode-number arrays real portals send.

Regular type=series and Ministra is_series items are unaffected; Xtream is
unaffected (get_series_info is never cached).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 18:03:33 +02:00
4grayandClaude Opus 4.8 8e1320cb34 feat(tmdb): series production-status chip and person death dates (#1240)
Two fields TMDB already sends us and the merge threw away — no new API
calls, no cache-key bump, they light up on existing cached payloads.

Series detail views (Xtream and Stalker) gain a production-status chip:
"Ended" tells you a show is finished before you commit to it, "Returning"
that it is not. TMDB returns `status` as an ENGLISH string even under
language=ru-RU, so it is normalized to a stable token
(normalizeSeriesStatus) and rendered through translated labels
(seriesStatusLabelKey). Unknown values are dropped rather than shown, so
a status TMDB adds later can never leak raw English into 19 locales.

Person pages render `deathday`, which mapPersonProfile has always parsed
into ActorProfile and no template ever read.

i18n: 7 keys across all 19 locales via the tools/i18n workflow.
Tests: status normalization (token mapping, case-insensitivity, the
British "cancelled" spelling, unknown/missing dropped).
Docs: tmdb-metadata-enrichment.md, CLAUDE.md.

Refs docs/architecture/tmdb-roadmap.md C1 and the zero-extra-call tier.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:50:56 +02:00
4grayandClaude Opus 5 e24da447c1 fix(ci): restore green master pipeline (hu locale drift, CodeQL upload) (#1237)
Two independent CI failures on master:

1. `Check i18n drift` failed with 3 keys missing from `hu.json`
   (`SETTINGS.PLAYER_UP_NEXT_RAIL`, `SETTINGS.PLAYER_UP_NEXT_RAIL_DESCRIPTION`,
   `PORTALS.UP_NEXT`). PR #1231 added them to every locale, but its branch
   predates the Hungarian locale merged in #1236, so `hu.json` never got them.
   Both PRs were green in isolation. The failure also aborted the job before
   the Tier A/B/C unit suites ran. Added the keys with real Hungarian
   translations rather than English fallbacks.

2. CodeQL has failed on every master push for days. The analysis itself
   completes; only the SARIF upload fails with "Resource not accessible by
   integration" because the workflow has no `permissions:` block and the
   default token is read-only. Added the standard grant.

While in that workflow: bumped `actions/checkout` v3 -> v4 (matches every other
workflow here) and dropped the obsolete `git checkout HEAD^2` step — the old
template's PR-head trick that current codeql-action handles itself, and the
only reason `fetch-depth: 2` was needed.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:59:16 +02:00
4grayandClaude Opus 5 4ca2b6852e feat(playback): Up Next episode rail for the inline series player (#1231)
* feat(playback): Up Next episode rail for the inline series player

On wide windows the inline series player now docks left and fills the
leftover stage column with a Netflix-style "Up Next" rail: the rest of the
current season plus next-season spillover, the playing episode highlighted,
and watch-progress bars from playback positions. Clicking an episode plays
it inline through the host's existing episode flow (Xtream serial-details
and Stalker series view).

- New app-up-next-rail component + buildUpNextRailItems() util in
  ui/playback; entries carry the host's raw episode object so selection
  needs no id lookup.
- PortalInlinePlayerComponent measures the theater stage with a
  ResizeObserver and docks the rail only when the leftover beside the 16:9
  player is >= 320px; narrower stages keep the centered theater/ambient
  behavior from #1223. Movies and live never show the rail.
- New playerUpNextRail setting (Settings > Playback, default on, built-in
  web players only), mirroring playerAmbientMode; enforced at runtime for
  non-web engines.
- i18n: SETTINGS.PLAYER_UP_NEXT_RAIL(+_DESCRIPTION) and PORTALS.UP_NEXT in
  all 18 locales.
- The rail renders as an opaque panel on top of the stage, so the ambient
  fill stays behind it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): address Greptile review on the Up Next rail

- Stage overflow: `.player-shell__viewport` had no border-box sizing (the repo
  has no global reset), so the docked-rail modifier's 12px padding widened the
  stage past its container and the right edge was clipped.
- Width gate: compute the width the rail actually receives (stage minus the
  docked layout's padding, the height-driven 16:9 player, and the flex gap)
  instead of raw stage slack, and observe the stage's border box so the
  modifier's own padding cannot feed back into the measurement.
- Stalker lazy seasons: Ministra VOD-series seasons hold no episodes until
  opened, so the rail's next-season spillover stopped at the current season.
  Prefetch the following season while an episode plays inline.

Adds regression coverage for the gate boundary, gate stability across the
padding toggle, and the lazy-season prefetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stalker): stop the rail spillover prefetch from retrying forever

A failed or genuinely empty Ministra season resets isLoading while leaving
episodes empty, so the prefetch effect re-requested the same season on every
emission for as long as inline playback continued. Remember which seasons this
view already requested and ask at most once each.

Regression test asserts the empty-response case fetches exactly once and does
not retrigger on further playback in the same season.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stalker): let a failed spillover prefetch recover on the next episode

The previous guard was permanent, so a transient network or authorization
failure disabled the rail's next-season prefetch for the component's lifetime.
Distinguish the two outcomes instead:

- Answered (even with zero episodes) — a real answer, never asked again.
- Failed — the claim is released, but pinned to the episode that triggered it,
  so the retry waits for the next playback change. Retrying immediately would
  loop, since the failure itself flips isLoading and re-runs the effect.

The claim is taken synchronously; awaiting first let the isLoading flip re-run
the effect and fire a duplicate request before the answer arrived.

`loadEpisodesForSeason` now reports whether the portal answered; existing
callers ignore the result and are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:16:09 +02:00
4grayandClaude Fable 5 b94f40dc74 feat(i18n): add Hungarian translation (hu) (#1236)
Integrate the community-contributed Hungarian translation by
Tibor Hermann (@htibcsike) as the 19th locale:

- add apps/web/src/assets/i18n/hu.json (1,142 of 1,175 keys translated;
  32 keys added after the contribution fall back to English, plus the
  new LANGUAGES.HUNGARIAN endonym)
- register HUNGARIAN = 'hu' in the Language enum, SUPPORTED_LANGS,
  Angular date locale registration, and the TMDB language map (hu-HU)
- add LANGUAGES.HUNGARIAN = "Magyar" to en.json and all other locales
  via tools/i18n/fill-missing.mjs
- update README.md and CLAUDE.md language counts to 19

Closes #1192, refs #1140.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:20:16 +02:00
4grayandClaude Fable 5 0ee73f2d0f feat(m3u): support #KODIPROP lines placed before #EXTINF (#1234)
* feat(m3u): support #KODIPROP lines placed before #EXTINF

Bumps the iptv-playlist-parser fork pin to v0.15.2-iptvnator.2: Kodi
property lines apply to the next list entry, so #KODIPROP lines placed
above the #EXTINF are now preserved in item.raw (previously the parser
dropped them and ClearKey config in that layout was lost). The DASH +
ClearKey feature (#1225) extracts license config from item.raw, so both
KODIPROP layouts now work on every import path.

Covered by a parser contract case and an extended web-backend /parse
regression (before-EXTINF + between-EXTINF-and-URL + plain channel).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: reflect before-#EXTINF KODIPROP support in the M3U architecture doc

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: list the KODIPROP delta in the parser-fork inventory

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 11:43:58 +02:00
4grayandClaude Opus 5 f852bc7459 fix(playlists): report failed playlists in the startup auto-refresh toast (#1235)
The startup auto-refresh always opened the
`HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SUCCESS` snackbar, even when the backend
had dropped playlists it could not refresh. Isolating per-playlist failures
(#1233) means the result set is lossy by design, so an unreachable source that
now fails within `PLAYLIST_FETCH_TIMEOUT_MS` produces a false success toast.

`autoUpdatePlaylists()` now returns `AutoUpdatePlaylistsResult` — the refreshed
playlists plus one outcome per requested playlist (`updated` / `failed` /
`skipped`), in request order — on top of the existing bounded-concurrency
refresh. The renderer derives the message from those outcomes:

- all updated -> `AUTO_REFRESH_UPDATE_SUCCESS` (unchanged)
- some failed -> `AUTO_REFRESH_UPDATE_PARTIAL` (error styling, dismissable)
- some failed and some skipped -> `AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED`
- none updated -> `AUTO_REFRESH_UPDATE_FAILED` (error styling, dismissable)
- only sourceless playlists left over -> `AUTO_REFRESH_UPDATE_SKIPPED`

Playlists with neither a URL nor a file path are reported as skipped rather
than failed, since there is no source to refresh them from. The mixed
failed+skipped message exists because the plain partial text names only
updated/total/failed, which would leave the skipped playlists as an
unexplained remainder. Titles of unresolved playlists are logged for
diagnosability.

Tests: five new `electron.service` cases (one per message branch), outcome
assertions across the existing `playlist-auto-update` and `playlist.events`
specs, and an Electron E2E that restarts the app against a killed playlist
server — verified to fail against the old unconditional toast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 11:33:07 +02:00
4grayandClaude Opus 5 fc81f4f941 fix(playlist): bound playlist downloads and isolate startup auto-update (#1233)
Playlist downloads in the main process ran without an axios timeout, so a
host that accepted the connection and then went silent kept the request
pending forever. The startup auto-update refreshed playlists sequentially,
so that one unresponsive source also withheld every playlist that did
refresh, and the URL import dialog could spin with no way out.

- Add PLAYLIST_FETCH_TIMEOUT_MS (30s) to the main-process fetch and reuse it
  in the refresh worker instead of its duplicated literal. Redirects are
  followed one hop at a time, so each hop is bounded separately.
- Move auto-update into playlist-auto-update.ts and refresh at most
  AUTO_UPDATE_CONCURRENCY (3) playlists at once, isolating each failure while
  preserving the requested order. An unbounded fan-out would download and
  parse arbitrarily many large M3U files in the main process at once.
- Redact refresh log URLs, which routinely carry Xtream username/password
  query parameters.

The existing auto-update spec handed out fixtures by call order, which no
longer holds once refreshes overlap; it now keys them by source type.

Fixes #931

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 10:37:11 +02:00