Opening an .m3u/.m3u8 file from the command line or a file association did
nothing. The renderer parsed `process.argv` and sent an `OPEN_FILE` IPC event
that had no `ipcMain` handler and no preload channel, so `sendIpcEvent` logged
it as an unknown type and dropped it.
The path now belongs to the main process, which is where the OS actually
delivers it:
- argv is parsed on first launch (skipping the executable and Chromium
switches) and normalized to an absolute path;
- macOS gets an `open-file` listener registered before `whenReady`, since
Launch Services never puts the path in argv;
- the single-instance guard forwards a second launch's argv and working
directory instead of discarding them, so opening a playlist against a
running app works too.
Requests are queued in the main process until the renderer subscribes to the
`OPEN_FILE` push and drains the queue, which closes the startup race. The
import itself reuses the existing file path, so persistence, playlist-scoped
EPG and the navigation to the new playlist behave exactly like a dialog
import; a failed open now surfaces a snackbar instead of silence.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`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>
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#1156Closes#102
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.
* 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>
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>
Category rows crossed the DB-worker IPC boundary with Drizzle's camelCase
property names while the renderer contracts declare snake_case, so backup
export dropped hidden-category IDs and restore degraded to a type-only
match that hid every category. Project category ops to the declared wire
shape, normalize restore state from untrusted sources (dropping entries
without a numeric xtreamId), reject entries with missing user-state
collections, and add full export→import round-trip coverage (unit
manifest-equality + Electron e2e) plus regression tests.
Closes#1017
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(m3u): extract ClearKey DRM from #KODIPROP playlist lines
Adds the typed ChannelDrm model (shared interfaces) and a KODIPROP
post-processing step in createPlaylistObject() — the single funnel for all
four playlist import paths. Parses inputstream.adaptive.license_type,
license_key and drm_legacy; ClearKey keys accepted as kid:key hex pairs,
W3C ClearKey license JSON, or a plain kid→key JSON map. Unsupported license
types (Widevine/PlayReady/license URLs) are preserved with supported=false
so playback can surface a DRM diagnostic instead of failing silently.
Also adds isDashStreamUrl/isDashChannel helpers for DASH routing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(playback): add Shaka DASH source engine with ClearKey support
Introduces ShakaVideoSession (libs/ui/playback/src/lib/shaka-engine/): a
lazily imported shaka-player engine (separate lazy chunk, ~217 KB transfer)
owning attach/configure/load with an operation queue and generation guard
against channel-switch races. Channel ClearKey config maps to
drm.clearKeys; channels with an unsupported license type emit a
DrmOrEncryption diagnostic without starting an engine. Shaka errors are
classified into the existing playback diagnostics
(PlaybackDiagnosticSource.Shaka).
Wires the engine into both built-in players like hls.js/mpegts.js:
- HTML5: extension === 'mpd' branch in playChannel(); hls/mpegts/native
glue extracted to helpers to keep the component within the size budget
- ArtPlayer: customType 'mpd' in ArtPlayerSourceSession (+ getDrm seam)
- Shared controls: WebVideoControlsSource kind 'shaka' +
WebVideoShakaControls using the Shaka 5 text model (selectTextTrack(null)
hides subtitles; Player.setTextTrackVisibility no longer exists)
Adds a CJS shaka-player jest stub (video.js precedent) for web specs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(m3u): route DASH channels to the inline Shaka-capable player
DASH (.mpd) channels always play in a built-in web engine (radio
precedent): external MPV/VLC cannot receive KODIPROP ClearKey
configuration (VLC upstream #29465) and Video.js has no DASH bridge yet.
- shouldShowInlinePlayer() bypasses the external-player setting for DASH
- new shouldAutoLaunchExternalPlayer() guard consolidates the MPV/VLC
auto-launch conditions in the m3u-state effects (incl. catch-up path)
- the M3U page overrides the player for DASH channels: ArtPlayer stays
ArtPlayer, everything else falls back to the HTML5 player
- ChannelDrm is passed through ResolvedPortalPlayback into the synthetic
player-view channel
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): add offline DASH ClearKey fixtures and e2e coverage
Fixtures (apps/web-e2e/src/fixtures/dash/): ~4s VP9+Opus DASH, clear and
CENC-encrypted variants with fixed synthetic ClearKey credentials.
Content synthesized by ffmpeg; encryption done by Shaka Packager because
ffmpeg's mp4 muxer writes senc-only metadata (Chromium needs saiz/saio)
and cannot produce the subsample encryption the VP9 CENC binding
requires. Generation script + README document regeneration.
web-e2e (Chromium): import an M3U with KODIPROP ClearKey via raw text,
verify encrypted and clear DASH actually play (currentTime advances, no
diagnostic banner) and that an unsupported license type (Widevine)
surfaces the DRM diagnostic. Fixtures are served through Playwright route
interception with HTTP Range support; the Angular service worker is
blocked since SW-routed requests bypass interception.
electron-backend-e2e: the same happy path + negative against a local
Range-aware fixture server — the automated proof that ClearKey EME works
in the real Electron runtime (file:// secure context).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: document DASH + ClearKey playback architecture
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(pwa): extract KODIPROP DRM on the web-backend /parse import path
The web-backend keeps its own playlist builder for the PWA URL-import
path, so the shared createPlaylistObject() DRM hook never ran there and
encrypted DASH channels imported by URL reached Shaka without keys.
Apply extractDrmFromRaw() in that builder too and cover the path with a
regression test.
Addresses Codex review on PR #1225.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): interrupt stalled Shaka loads and destroy failed engines
Two review findings on the ShakaVideoSession lifecycle:
- stop()/start() now tear the current player down immediately instead of
queueing the destroy behind the in-flight operation. Shaka's destroy()
interrupts a pending load() (LOAD_INTERRUPTED), so a stalled manifest
fetch can no longer wedge the operation chain and block the next
channel start (Codex P1).
- A rejected attach()/load() now destroys the failed player after
emitting the diagnostic, so a non-functional engine never stays
attached to the media element or exposed to the shared-controls
bridge (Greptile P1).
Regression tests cover both paths. The Shaka fakes are consolidated into
a shared jest-free test double that mirrors the destroy-interrupts-load
semantic, and the ArtPlayer source-session spec is split (fixtures +
DASH cases) to stay within the max-lines lint budget.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(m3u): unify DASH URL detection with playback extension normalization
isDashStreamUrl() used the simpler getStreamExtensionFromUrl(), so URLs
the player engines classify as DASH (stream.MPD, ?ext=mpd, ?format=mpd)
were not routed to the Shaka-capable inline player and lost their
ClearKey metadata with Video.js or external players configured
(Codex P2). The normalized getPlaybackMediaExtensionFromUrl() now lives
in @iptvnator/shared/m3u-utils (re-exported unchanged from the playback
lib) and both routing and engine selection share it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(lint): satisfy CI lint and CodeQL in DASH support files
- replace shell-built tar/npm commands with execFileSync arg arrays in
the fixture generator (CodeQL: uncontrolled shell command)
- give jest stub methods explicit bodies (no-empty-function)
- compact the diagnostic label switches in WebPlayerViewComponent to
stay under the max-lines budget
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): tear down the Shaka engine on critical error events too
A non-recoverable Shaka error emitted after a successful load left the
dead engine attached to the media element and exposed to the
shared-controls bridge (Greptile P1, round 2). Critical error events now
destroy the player right after the diagnostic is emitted, matching the
load-failure path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(m3u): honor DASH catch-up URLs and drop unusable DRM fallbacks
Two Codex round-2 findings:
- The inline-playback DASH gate only examined the channel URL, while the
external-player guard checks the resolved catch-up URL — a replay that
resolves to an .mpd manifest with MPV/VLC configured ended up with no
player at all. The gate now uses the effective playback URL
(activePlaybackUrl ?? channel.url).
- The unsupported-DRM diagnostic advertised MPV/VLC fallback actions,
but external players cannot receive the KODIPROP license config either
— the diagnostic no longer recommends them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): suppress unusable external fallback for ClearKey DRM failures
Runtime DRM errors on channels that carry KODIPROP ClearKey config (wrong
or rotated keys) advertised MPV/VLC fallback actions, but external
players never receive the license config — the fallback could only fail
differently. DRM-classified diagnostics from such channels no longer
recommend external players; clear channels keep the hint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(m3u): symmetric DASH inline gate and lazy DRM for pre-upgrade playlists
- The inline DASH gate is now true when either the channel or the
resolved catch-up URL is DASH, mirroring the external-player guard —
a .mpd channel whose catch-up resolves to .m3u8 no longer ends up
with no player at all.
- Playlists imported before the DRM feature carry no drm field, but the
raw KODIPROP block survived in the stored items; the M3U page now
falls back to extractDrmFromRaw(channel.raw) at playback time, so
encrypted channels work without a re-import (Channel gains raw?).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: sync the DASH/Shaka contract across agent docs
Mirrors the DASH/Shaka source-engine contract into AGENTS.md and adds
Shaka to the shared web-video bridge descriptions in CLAUDE.md and the
player-controls contract; documents the lazy raw-KODIPROP DRM fallback
for pre-upgrade playlists in the M3U architecture doc.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): reset the media element for rejected DRM and widen ClearKey fallback suppression
- Switching from a playing stream to an unsupported-DRM DASH channel
loads no new source, but play() still ran and the un-loaded element
could resume the previous stream underneath the diagnostic banner.
The HTML5 player now resets the element instead of playing.
- Any inline failure on a KODIPROP ClearKey channel (manifest, codec,
media, network — not just DRM-category errors) is unsolvable in
MPV/VLC, which never receive the license config; the external
fallback hint is now suppressed for all diagnostics of such channels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): restore suppressed DASH captions when the preference re-enables
The Shaka bridge dropped the auto-selected text track with
selectTextTrack(null) when showCaptions was off, but did not remember it
— re-enabling the preference mid-session left captions permanently off
(HLS/native bridges already restore). The session now remembers the
suppressed track id and reselects it via the bridge's caption-state pass;
suppression is also skipped when no track is active. Covered by session
and new WebVideoShakaControls specs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: retrigger CI
GitHub Actions created no check suites for the last three pushes to this
branch (third-party apps received the webhooks); an empty commit re-fires
the push and pull_request events.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(playback): split oversized Shaka session and HTML5 spec files
CI lint enforces max-lines 400: extract ShakaTextTrackSuppression and the
shaka-error helpers out of ShakaVideoSession, and move the DASH-specific
HTML5 player test into its own spec. No behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci: allow manual dispatch of the cross-platform E2E workflow
GitHub stopped delivering push/pull_request events for this branch;
workflow_dispatch provides a manual escape hatch (CI and build-and-make
already have one).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Adds a paused state to the Electron download manager with a full partial-file lifecycle:
- Pause keeps the .part and byte progress; cancel discards them; every lifecycle stage (queued, active, mid-transfer) is pausable.
- Resume continues via HTTP Range with If-Range entity validation (strong ETag / Last-Modified persisted in the new resume_validator column, idempotent migration incl. legacy-table rebuild). Non-206 answers restart from zero over the same .part; the 206 Content-Range offset is verified; responses that end before the advertised size are retained for a Range retry instead of being committed as completed.
- Crash recovery converts interrupted transfers to paused, keeps queued-with-partial rows resumable, and commits finalizations that crashed before the DB update.
- Destination collisions are non-destructive (retained partials finalize to the next numbered name); locked .part files never lose their DB owner across cancel/remove/restart; resume claims rows atomically and the queue dedupes ids.
- Stored request headers are re-filtered through the User-Agent/Origin/Referer allowlist on read, URL-derived extensions are sanitized, resume appends never follow symlinks, and transfer errors are logged by message only.
- UI: pause/resume/cancel/retry/remove surface failures in a snackbar; paused items show an active Resume button in VOD/episode detail views; translations for all 18 locales.
- Runtime split into download-runtime/transfer/finalize/broadcast modules; +30 unit tests and an Electron E2E covering pause -> retained .part -> Range/If-Range resume -> byte-exact assembly.
Co-authored-by: genrichh93-ui <genrichh93@users.noreply.github.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Load the complete ITV channel list once per portal (Ministra get_all_channels with a paged crawl fallback) and use it for: full local search across every channel, per-genre category count badges, an all-channels paginated grid on Live TV entry, and eager bulk EPG so row previews appear without playing a channel. Censored (adult) genres absent from get_all_channels fall back to the legacy paged flow and show no badge. Includes mock-server support, unit + E2E coverage, and architecture docs.
The shared saveSettings helper clicks the settings save control, which is a
native `<button type="submit">` inside `<form (ngSubmit)="onSubmit()">`.
Clicking it makes Chromium register a form-submission navigation that
Angular's ngSubmit handler immediately cancels via preventDefault(), so no
real navigation ever happens. Playwright's default post-click "wait for
signals" barrier still observes that requested-then-cancelled navigation and
waits for it to settle; on slow/loaded CI runners that wait can stall for the
full timeout ("waiting for scheduled navigations to finish").
When the click times out, the test is aborted and its finally block closes
the Electron app; the still-pending click then rejects late, after the test
has finished, so Playwright reports it at the worker level as "1 error was
not a part of any test" and exits non-zero even though the retry passed
(e.g. 78 passed / 1 flaky yet job fails).
Opt out of the barrier with { noWaitAfter: true } and keep asserting the
deterministic post-save state (the button disables once the settings write
resolves and the form is marked pristine), which is a stronger, race-free
confirmation that the save committed. Fixes the flaky radio-playback remote
control test and hardens every saveSettings caller in the suite.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* test(db): cover playback positions, recently viewed, and connection migrations
Add specs for the previously untested persistence paths: playback-position
and recently-viewed operations (upsert/dedup/ordering/scoped deletes),
shared-database path-utils, createTables and the tolerant column/index
migrations incl. Xtream cache deduplication. Exposes createTables through
the existing __databaseConnectionTestHooks object.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(xtream): cover the Electron DB-first data source and favorites guards
Add specs for electron-xtream-data-source (DB-hit vs cold-cache paths,
concurrent request dedup, error propagation, full method delegation) and
extend the favorites feature spec with the invalid-input and
content-not-found guard paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): cover favorites and recent store features
Add specs for with-stalker-favorites and with-stalker-recent: payload
normalization and id/title fallbacks, series-mode category forcing, meta
sync dispatches, snackbar/callback side effects, and error paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(epg): cover archive/summary utils and the EPG worker service
Add specs for the pure catch-up window and summary-progress helpers shared
by the EPG panels, and for epg-worker.service: in-flight dedup by URL,
double-settle guard, progress-aware timeouts, worker lifecycle and error
broadcasting. Also settle an interrupted fetch in epg.events.spec that
caused "Cannot log after tests are done" in longer runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(player): cover the VLC session service
Mirror the MPV session spec patterns for VLC: enqueue-command building and
RC response parsing, launch argv construction, instance reuse over the RC
socket, exit-code handling, and the retry-without-RC fallback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): add downloads page and EPG timeline interaction coverage
Downloads: empty state without sources, and a full lifecycle - authorize a
folder via a stubbed native dialog, download from a local server, verify
the completed item and file on disk, remove it from the UI. Timeline: zoom
changes block widths and the on-air info affordance opens the programme
dialog with the correct title and watch-live action.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: split oversized specs to meet the file-size guideline
Address review feedback: extract shared drizzle mocks into
operations.test-helpers.ts and split the Electron data-source delegation
spec into delegation + user-data files. Pure reorganization - test counts
and assertions unchanged (29/13/10), all files now under 300 lines.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): wait for DB readiness before opening the downloads page
On slow CI runners (macOS) the renderer can query SQLite while the DB
worker is still creating tables, leaving the downloads page on its
skeleton state forever. Poll a playlist read until it succeeds before
navigating on a cold profile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(build): exclude *.test-helpers.ts from the electron-backend app tsconfig
The new operations.test-helpers.ts uses jest globals and broke the webpack
build and tsc typecheck, which compile every non-spec file in the app.
Exclude the test-helpers pattern alongside the existing spec exclusions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(epg): add vertical list view for the live EPG panel
Add an EPG list view — a vertical, single-day programme list — as an
alternative rendering of the live EPG panel, selectable via a new
Settings → EPG → "Guide view" toggle (epgViewMode: 'timeline' | 'list',
default 'timeline' so existing users see no change).
- New EpgListViewComponent (app-epg-list-view) mirrors
EpgTimelineComponent's input/output contract 1:1, so all four live
hosts (M3U player, unified live tab, Xtream, Stalker) swap the panel
with a plain @if and identical bindings.
- Reuses the shared view-agnostic EPG modules (classifyTimelineWhen,
hasProgramsForDateKey, epg-archive.util catch-up gating,
epg-summary.util collapsed-summary maths, epg-date helpers,
EpgProgrammeDialogService, app-epg-timeline-empty-state) — no
duplicated logic.
- Rows show time range, title, optional description, live progress on
the on-air row, catch-up "Watch" on past rows when archive playback
is available, and a details dialog; keyboard activation guards
nested buttons (target === currentTarget).
- Auto-focuses the on-air row on channel select, restores it across
collapse/expand remounts, and shows a sticky in-flow "On now" strip
(never overlaying rows) when the current programme is scrolled away;
all scroll maths is rect-based relative to the scroller.
- List mode raises only the inline panel height via an epg--list
modifier (--epg-inline-height clamp); timeline and collapsed heights
are unchanged.
- Setting flows end-to-end (Settings interface → DEFAULT_SETTINGS →
SettingsStore/StorageMap → segmented control in the EPG section);
Electron-only UI, PWA stays on the timeline default. i18n keys added
to all 18 locales.
- Tests: new component/row/utils/scroll-controller specs, settings
persistence spec, swap tests in all four host specs, and Electron
E2E for the settings round-trip and the rendered list view. Docs
updated (m3u-playlist-module.md).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(epg): address list-view review findings from Codex and Greptile
- Reset the list view to today when a new channel's programme set
arrives while the user is parked on another day (timeline parity):
the scroll controller now keys by the full programme-set identity
(programsFocusKey) and commits today before focusing, instead of
silently stranding the new channel on the stale day. (Codex P2)
- Centralise the 'timeline' fallback as a resolvedEpgViewMode computed
on SettingsStore; the four live hosts consume the derived signal
instead of duplicating the `?? 'timeline'` expression. (Greptile P2)
- Extract the component's reactive plumbing into
registerEpgListViewEffects(), bringing the component back under the
300-line guideline (290). (Greptile P2)
- Controller spec rewritten around programme-set fixtures with new
coverage: return-to-today on channel switch, day navigation left
alone, no-takeover when today has no data, empty-set no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(epg): drop malformed programmes from the list-view day filter
Reject programmes whose stop is not after their start in
buildEpgListRows — same as the timeline's buildTimelineBlocks. Bad
provider data would otherwise render impossible time ranges and could
even be offered as catch-up playable. (Codex P2 on e6fd0d08)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the vertical EPG list with a shared horizontal `app-epg-timeline`
ribbon across all live surfaces (M3U player, unified live tab, Xtream, Stalker):
zoom, day navigation, short-programme grouping, catch-up/timeshift, and
per-state empty views. Backend gains timezone-aware `datetime()` comparisons,
unscoped source fallback, non-ASCII candidate matching, and chunked candidate
queries.
Timeline split into reusable, view-agnostic modules (archive/summary/dialog
service/render util/scroll controller) for the future EPG list view.
Fixes landed during review:
- honor the controlled `selectedDate` input (seed via linkedSignal)
- restore ribbon position across collapse/expand
- keep the ribbon mounted when scrolling across a gap day
- don't trigger block playback on Enter from nested watch/info buttons
- don't reset timeshift playback on the 30s now-tick during EPG gaps
Greptile 5/5 (safe to merge); Codex clean; CI green.
Adds IMDb rating sort and minimum-rating filtering for Xtream VOD/series catalogs, consolidates refinement controls, and guards rating controls away from Live TV.
* feat(ui): add custom title bar window controls for Windows and Linux
Hide the native title bar on win32/linux (titleBarStyle: 'hidden', frame
untouched so native resize borders and snapping keep working) and render
minimize / maximize-restore / close buttons in the renderer, mirroring the
existing macOS traffic-light setup.
- New WINDOW:* IPC contract (minimize, toggle-maximize, close, get-state)
handled in window.events.ts, resolved from the sender WebContents;
close goes through win.close() so window-bounds persistence still runs.
- WINDOW:STATE_CHANGED pushed on maximize/unmaximize/fullscreen so the
maximize/restore glyph stays correct for OS-triggered changes; controls
hide while fullscreen.
- WindowControlsComponent mounts once in app-root as a manual popover so
it stays in the browser top layer above CDK overlays (dialogs,
multi-EPG) - same behavior as macOS traffic lights.
- Theme-aware via CSS vars (--app-on-surface, --app-hover-overlay);
Windows-red close hover. Drag regions get right padding through a
body-level frameless-platform class.
- Gated by RuntimeCapabilitiesService.usesCustomWindowControls; PWA and
macOS never mount the controls.
Includes unit specs for the component and IPC handlers, an Electron E2E
suite (window-controls.e2e.ts), and a window-chrome section in
docs/architecture/workspace-shell.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(linux): upgrade Electron to 41 for frameless window decorations on Wayland
With the native title bar hidden, Linux windows lost the WM-drawn shadow
and rounded corners. Electron draws client-side decorations only on
native Wayland, and frameless-window CSD (GTK drop shadow + extended
resize boundaries) landed in Electron 41 - before that, frameless
windows render as plain rectangles.
- electron ^39.8.5 -> ^41.7.2 (Wayland auto-detected since 38.2; X11
sessions remain undecorated, matching other frameless Electron apps;
Windows keeps its DWM shadow and rounded corners).
- better-sqlite3 pinned to exactly 12.9.0: the last release shipping
prebuilt binaries for both Node 20 (ABI 115, Jest) and Electron 41
(ABI 145, runtime). 12.10.0 dropped the Node 20 prebuilds, forcing a
from-source build that fails without a C++ toolchain.
- pnpm override node-abi 3.85.0 -> 3.92.0 so electron-builder
install-app-deps can map Electron 41 to ABI 145.
Reviewed Electron 40/41 breaking changes: only the renderer clipboard
deprecation, which this app does not use.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(e2e): address review feedback and window-managerless Linux CI
- Skip the three window-manager-dependent E2E assertions (maximize
toggle, main-process state sync, minimize) on Linux CI: GitHub's
ubuntu runners drive Electron under xvfb without a window manager, so
maximize/minimize state never materializes there. Windows CI and
local Linux/macOS runs keep the coverage.
- WINDOW:TOGGLE_MAXIMIZE now returns the requested state instead of
re-reading isMaximized() right after the call, which races on Linux
window managers where maximize()/unmaximize() complete
asynchronously; the WINDOW:STATE_CHANGED push stays authoritative.
- Skip attaching window-state push listeners on macOS, where the
custom controls never mount and the IPC traffic had no subscriber.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): gate custom window controls on the full bridge surface
Include getWindowState and onWindowStateChange in the
usesCustomWindowControls capability check — the controls rely on both
for initial state and for keeping the maximize/restore glyph in sync
with OS-triggered changes, so a partial bridge should not mount them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Two Electron E2E regressions from this branch's redesigns:
1. Hiding the playlist switcher on /settings (a playlist-scoped control)
also hid the global "Add playlist" button, which lives in the same
header-actions block. A user configuring Settings before importing
their first source had no way to add a playlist — and the settings /
playlist-switcher E2E suites, which add a portal right after saving a
setting, timed out waiting for the button. The Add Playlist button is
a global action, so it now stays visible on Settings; only the
per-playlist context shortcut and bulk actions are hidden there.
2. dashboard-activation.e2e.ts asserted on `dashboard-global-favorites-
rail`, the mixed Favorites rail removed earlier in this branch. The
test now targets the v0.22 rails: live favorites resolve through the
favorites-first `dashboard-live-recent-rail`, and the played movie /
series resolve through `dashboard-continue-watching-rail` (renamed
from `dashboard-recently-watched-rail`). The movie-from-favorites
assertion was dropped — that dashboard surface no longer exists; the
favorites collection still has its own page-level coverage.
Verified locally: dashboard-activation, settings, and playlist-switcher
E2E specs all pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The redesign rollup in this branch replaced the Add Playlist dialog's
2-level type × subtype tabs with a single flat 5-card radiogroup, and
replaced the Settings theme list with a compact segmented control whose
options are just "Light"/"Dark"/"System" (no "theme" suffix). E2E tests
that pinned to the old roles/labels failed in CI:
- basic / xtream / stalker / self-hosted: getByRole('tab', ...) on
"Add via file upload" / "Xtream" / "Stalker" — there are no tabs in
the new dialog. Switched to getByRole('radio', { name: /M3U file/i })
and friends, matched against the new radio labels.
- settings: getByRole('radio', { name: 'System theme' }) — the new
picker uses just "System". Scoped to the [data-test-id="select-theme"]
radiogroup so it doesn't collide with the identically-labelled
cover-size options below.
The Electron `clickDialogSegmentedOption` helper grew a radio-role lookup
as its primary path and keeps the old tab/button/legacy-selector
fallbacks so a future redesign won't break every fixture again. The
"M3U" parent category becomes a no-op (the new picker has no standalone
M3U tile — callers always specialise to M3U URL/file/text immediately
after, which is what we want anyway).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>