Commit Graph
64 Commits
Author SHA1 Message Date
4gray e9eca1c386 chore(deps): upgrade Angular to 22.1 and Nx to 23.2 (#1603)
* chore(deps): upgrade Angular to 22.1 and Nx to 23.2

* fix(deps): complete Angular migrations after rebasing on master

* fix(ci): use the Node pin for Windows runtime refresh

* docs(deps): synchronize the workspace-shell Node requirements
2026-09-14 19:02:40 +02:00
d438f5c655 feat(epg): accept local XMLTV files as EPG sources (#1600)
* feat(epg): accept local XMLTV files as EPG sources

Settings → EPG and the playlist dialog accepted `file://` in their form
pattern, but the main process rejected everything except http(s), so a local
XMLTV entry saved fine and then failed on import. Both surfaces now take a
remote link, a `file:` URL, an absolute POSIX path or a Windows drive/UNC
path (`classifyEpgSourceReference` in shared/interfaces), and the settings
section spells out the accepted formats with examples.

The EPG worker opens every source through `openEpgSourceStream`: remote
links keep the validated-redirect client and trust policy, local files are
read from disk behind the signature-sniffing optional gunzip stage, so
.xml, .xml.gz and extension-less gzip all parse. Only hand-typed sources
may be local: `extractM3uEpgUrls` harvests http(s) links only from M3U
headers, since the local branch bypasses `validateRemoteUrl`.

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

* feat(epg): pick local XMLTV files with a native file dialog

A folder button beside each EPG source row (Settings → EPG and the playlist
dialog) opens the native open-file dialog and writes the chosen absolute
path into the row. New `EPG_OPEN_FILE_DIALOG` IPC behind
`ElectronBridgeApi.openEpgFileDialog`, gated in the renderer by
`RuntimeCapabilitiesService.supportsEpgFilePicker`.

The row's refresh/remove buttons carry `data-test-id`s now, and the EPG
e2e suites address them by id instead of index, since the folder button
became the first button in a row.

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

* fix(epg): authorize local XMLTV files in the main process

Review follow-up (Greptile P1, Codex P1). The renderer hands source strings
to FETCH_EPG/EPG_FORCE_FETCH unchanged, so the form validator alone could
not enforce the provenance rule: a compromised renderer, or a legacy
`file://` entry an older version stored from an M3U header, could name any
file on disk.

`EpgWorkerService.startFetch` now asks a main-process
`EpgLocalSourceAuthorizer` before a local path reaches the worker: a path
the native picker returned is trusted at once, a hand-typed path is
confirmed once in a native message box the renderer cannot fake, and a
refusal is reported in the progress panel. Allowed paths persist under
TRUSTED_LOCAL_EPG_SOURCES in the main-process config. The worker opens its
local branch only when main set `allowLocalFile`; the service defaults to
deny-all until epg.events installs the persisted authorizer.
`resolvePlaylistEpgSourceState` and `filterPlaylistEpgUrlsForFetch` drop a
stored non-remote entry unless it is also in `manualEpgUrls`.

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

* fix(epg): fail a refused local EPG fetch instead of resolving it

Review follow-up (Codex P2). A denied native confirmation now rejects the
fetch after reporting the error row, so handleFetchEpg and the renderer's
fetch result cannot claim the file was read. Also restores the unrelated
CLAUDE.md paragraph an earlier formatter pass had reflowed into a list.

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

* fix(epg): cancel a local source retired during its authorization prompt

Review follow-up (Codex P2). startFetch keeps the request generation
captured before awaiting the native confirmation and rechecks it
afterwards: a source retired meanwhile ends as cancelled instead of
starting an import that a pending clear would then have to await.

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

* test(epg): leave the local XMLTV e2e with a pristine settings form

The local-file test ended with the EPG source field still dirty, which
arms the main-process close guard: the app then waited for the unsaved
changes dialog instead of closing, the close timeout killed it, and on
Windows the killed process kept iptvnator.db busy (EBUSY on the data-dir
cleanup) and hung the Playwright worker teardown. Discarding the form
before the app closes takes the test from 15 s to 4 s locally.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 21:04:41 +02:00
4gray bd848aaad6 feat(playlist): clean up selected inactive desktop sources (#1593) (#1596) 2026-09-13 10:39:14 +02:00
4gray 62655a8b5d feat(playlist): show desktop health indicators for network sources (#1592) 2026-09-12 23:05:17 +02:00
4gray 7d1265d566 fix(xtream): detect HTTP portals during explicit connection tests (#1588) 2026-09-12 15:30:22 +02:00
4gray 0ba5107561 fix(m3u): use custom User-Agent for URL import and refresh (#1535) 2026-09-05 14:49:23 +02:00
4gray bee7df1e02 feat(playlist): auto-detect import method that parses pasted provider messages (#1445)
Adds an "Auto-detect" method to the Add playlist dialog: paste the message a
provider sent — links, Xtream credentials, a MAC address with device identity
— and a deterministic parser recognizes the source(s) and prefills the
matching import form.

- detectProviderImportCandidates (libs/shared/interfaces) extracts URLs, MAC
  addresses and labeled fields, classifies each finding as Xtream, Stalker or
  an M3U link/body, and returns ranked candidates. Pure and synchronous.
- Built against a corpus of 19 real reseller handouts kept verbatim in the
  spec: Unicode "font" labels, arrow/dingbat separators, separator-less hex
  serials, dual device IDs, multi-MAC lists, bare three-line handouts, and a
  guard so a parental PIN is never read as the account password.
- Detection only proposes: the target form's own validation and behavioral
  probes remain the sole path into the store, and no pasted text leaves the
  app. Passwords are masked on candidate cards, including query and HTTP
  Basic userinfo forms.
- Covered by parser, component and dialog unit tests plus two web E2E specs
  for the paste → pick → prefilled form workflow; i18n for all 19 languages.
2026-08-16 13:19:54 +02:00
4gray 365cf35317 refactor(portals): extract the destructive Xtream refresh into one flow (#1431)
The header action and the Workspace sources page each had their own ~60-line
implementation of the destructive Xtream refresh, which is why #1421 had to fix
the same connectivity-guard bug twice. `XtreamRefreshFlowService` now owns the
sequence once; the entry points supply only an `XtreamRefreshProgressReporter`,
so neither can reach the guard reset and skip it.

Extracting it surfaced a pre-existing race that neither entry point could have
fixed alone: the two guards were independent, so the same playlist could be
refreshed from both at once and the second run parked an already-emptied catalog
over the first run's snapshot, losing favorites, history, hidden categories and
playback positions. The shared flow now serializes runs per playlist, refusing a
second one before the guard reset.

Found by Greptile; the ordering is pinned by a test that fails when the check
moves below the reset.
2026-08-13 17:33:22 +02:00
4gray e3f72f7dce perf(portals): fast-fail requests to portal hosts that stopped answering (#1421) 2026-08-13 07:31:23 +02:00
4gray 6ad9f3ff8a feat(stalker): discover portal endpoints on add and edit (#1391)
* feat(stalker): discover portal connection on edit

* docs(stalker): document smart endpoint discovery

* fix(stalker): make edited connection persistence atomic

* fix(stalker): serialize edit discovery

* fix(stalker): fence all edit authentication

* fix(stalker): serialize overlapping edits

* fix(stalker): hydrate playlist identity before edit

* test(stalker): await edit hydration

* fix(stalker): release abandoned edit fences

* fix(stalker): reject stale repairs before discovery

* fix(stalker): fence stale portal modes

* test(stalker): align simple portal session guard

* fix(stalker): reject superseded portal responses

* test(stalker): await settled append failure

* fix(stalker): retain abandoned auth fences

* fix(stalker): fence abandoned discovery retries

* fix(stalker): retire restored repair overrides

* fix(stalker): verify repair override retirement

* fix(stalker): preserve edit-owned repair tokens

* fix(stalker): defer repair retirement during edits

* fix(stalker): fence repair history reads

* fix(stalker): fingerprint portal URL credentials

* fix(stalker): persist submitted identity after navigation

* fix(stalker): merge late connection saves

* fix(stalker): keep edits off Xtream save path

* fix(stalker): preserve concurrent edit state

* fix(stalker): reject replaced late edit targets

* fix(stalker): guard every resolved edit write

* fix(stalker): make pwa edit guard transactional

* fix(stalker): migrate pwa flags transactionally

* fix(stalker): reserve pwa edits across tabs

* fix(stalker): coordinate playlist replacements with edit

* fix(stalker): reserve lazy repairs across tabs

* fix(stalker): drain local repair before edit lock

* fix(stalker): block queued repairs during edit drain
2026-08-09 22:34:49 +02:00
4grayandClaude Opus 5 9ff1c6ae01 feat(stalker): identity hardening (#1370)
MAC addresses are canonicalized to the uppercase colon form a real STB
sends and validated at the input boundary, with a hint when they fall
outside Infomir's OUI — which the stock server's default filter refuses
with a bare {status: 1} no user could diagnose. Normalization applies
only to a value the user actually edits: rewriting stored bytes would
move the session fingerprint for every existing playlist with no user
action, and the MAC is the account key.

Device IDs can optionally be derived from the MAC the way StbEmu and
stalker-to-m3u do — SHA256(MAC) and SHA256(MAC + "stalker"), which a
real box never reports as equal. The portal pins the first non-empty
device_id/device_id2 it sees to the MAC permanently, refuses a different
one, and treats a later empty value as an unrecoverable lockout, so
derived values are written into the visible fields and persisted as
literal strings, never recomputed at request time. The option is offered
at import only; the edit dialog warns instead once an ID has actually
reached the portal.

get_profile now reports one coherent MAG250 (ver, stb_type — previously
empty —, hw_version, image_version, client_type), and a device conflict
gets its own StalkerPortalError kind so the UI can explain it instead of
relaying the portal's "Your STB is damaged".

Closes the identity-fields cluster: #927, #860.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:09:07 +02:00
4grayandClaude Opus 5 6c065124ed feat(stalker): add account info dialog for Stalker portals (#1330)
* feat(stalker): add account info dialog for Stalker portals

Xtream playlists have had an account-info dialog for a while; Stalker
portals stored the same facts (login, expiry, tariff, status captured at
import) as dead weight in the database and showed them nowhere.

Add StalkerAccountInfoComponent mirroring the Xtream dialog's visual
language: status pill, days-left/tariff/MAC hero stats, account and
portal panels. Data is cached-first — the import-time snapshot renders
instantly with a "Saved data" badge, then StalkerAccountInfoService
refreshes it: full /stalker_portal/ installations re-run
handshake+get_profile, portal.php panels are queried best-effort via
account_info/get_main_info. A failed refresh keeps the cached snapshot;
no data at all shows a retry-able error state.

Entry points are unified behind shared portal-account predicates
(isXtreamAccountPlaylist / isStalkerAccountPlaylist in shared/interfaces)
so both portal types get the same set: header playlist switcher (bottom
section + new per-row ⋮ Account info item), dashboard source card ⋮ menu,
and the command palette (now visible on stalker routes with its own
description). The header service picks the dialog by playlist type; the
per-row path works for non-active playlists and skips the session-scoped
stream counts.

Also adds the missing top-level LOADING/RETRY i18n keys the Xtream dialog
already referenced (they rendered as raw keys), a get_main_info handler
in the stalker mock server, and STALKER.ACCOUNT_INFO translations for all
19 locales.

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

* fix(stalker): unwrap nested js.account_info envelope in get_main_info

Ministra-style portals nest the account block — fetchStalkerExpireDate()
in stalker-player-request.utils already consumes exactly that shape, so
the flat-only mapper silently discarded valid responses and legacy
imports (which have no cached snapshot) got an empty account panel.

Merge nested fields over flat aliases, send the JsHttpRequest parameter
the existing get_main_info caller sends, switch the mock server to the
nested envelope so the E2E covers the realistic shape, and document the
account-info feature in CLAUDE.md (review feedback from Greptile and
Codex on #1330).

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

* test(stalker): pin account-info expiry fixture below the day boundary

Math.round on the epoch could round up half a second, putting the
fixture's expiry just past the 30-day mark so daysLeft ceil'd to 31 on
CI. Floor keeps the interval strictly inside 30 days regardless of when
within the second the spec runs.

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

* refactor(stalker): address account-info review round two

Three P2s from Codex on #1330:

- Normalize the cached stalkerAccountInfo snapshot before rendering:
  the import path persists portal values verbatim, so expireDate can be
  a date string or milliseconds at runtime despite the declared number
  type. normalizeStoredStalkerAccountInfo() runs the same parsers as
  the fresh path.
- Publish the re-auth token into StalkerSessionService's cache: strict
  portals invalidate the previous token per handshake, so the dialog's
  authenticate() would otherwise strand an active portal session on a
  dead token.
- Extract the duplicated ~460-line account-dialog stylesheet into
  libs/ui/styles/_account-dialog.scss, shared by both dialogs with the
  provider accent injected via --account-dialog-accent; each consumer
  keeps only its accent and layout overrides.

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

* fix(stalker): serialize account-profile refresh with session auth

The dialog's direct authenticate() call bypassed the pendingAuth map
ensureToken() uses, so a refresh could run a second handshake while a
catalog or watchdog request was still authenticating. On strict portals
each handshake invalidates the other's token, and the later
setCachedToken() could publish an already-dead one.

Move the refresh into StalkerSessionService.refreshAccountProfile(): it
waits for any in-flight authentication, registers its own so later
callers wait for it, and republishes the resulting token. A failed
pending auth no longer aborts the refresh, and the pendingAuth entry is
only cleared when it is still this call's.

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

* fix(stalker): move pendingAuth cleanup out of the promise initializer

TS2454 under the Angular compiler: the finally block referenced
authPromise inside its own initializer, so every Electron/web production
build failed even though jest and lint accepted it. Await the promise at
the call site and retire the map entry there instead — same
only-clear-our-own-entry semantics.

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

* fix(stalker): harden account-info portal detection and expiry math

Review round four (Codex P2s on #1330):

- Fall back to the URL rule when isFullStalkerPortal is undefined: a
  playlist restored from an older backup carries no flag once the
  one-shot metadata migration has run, and it would then be sent down
  the unauthenticated legacy path and labelled a legacy panel.
- Parse a bare YYYY-MM-DD expiry as a local calendar date. Date.parse
  reads it as UTC midnight, which renders as the previous day west of
  UTC and shifts the days-left boundary; timestamps carrying a time or
  offset keep standard parsing.
- Decide expiry from the raw timestamp, not the rounded counter: an
  expiry that passed less than a day ago ceil's to 0/-0, so the hero
  stat claimed "0 days left" on a dead subscription.

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

* fix(stalker): make account-profile refresh own the auth slot

Review round five (Codex P2s on #1330):

- Claim the pendingAuth slot in a loop and publish it before the first
  await. One settled promise releases every waiter at once, so a single
  pre-check let two queued refreshes both start handshakes that
  invalidate each other on strict portals.
- Retire the cached token before the handshake: ensureToken() reads
  tokenCache before pendingAuth, so catalog and watchdog requests
  starting mid-handshake were handed a token this refresh was about to
  kill instead of queueing on the slot.
- Render the portal type from the same resolver the fetch path uses, so
  a restored backup without an explicit flag is no longer labelled a
  legacy panel while authenticating as a full portal.

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

* fix(stalker): retire only the token that actually failed auth

A request dispatched with the previous token can see its authorization
failure arrive after a profile refresh has already cached a fresh one.
The retry path deleted the cache blindly, killing the fresh token and
kicking off another handshake that in turn invalidated tokens of newer
requests — cascading retries on strict portals.

makeAuthenticatedRequest() now retires the cached token only while it
still equals the token that failed; a late failure of a stale token
leaves the refreshed token in place and the retry reuses it.

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

* docs(stalker): distinguish the two no-data outcomes of the account dialog

A portal that answers but publishes no account facts renders the
ready-state "No account details" panel; only an unreachable portal
without a cached snapshot enters the error state with retry. The doc
conflated both as "error with retry" (review feedback on #1330).

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

* fix(stalker): reject negative expiry sentinels before date parsing

Portals encode unlimited/missing expiry as "-1" or "0"; the
unsigned-digit check let "-1" fall through to Date.parse, which V8
reads as January 1, 2001 — an unlimited account rendered as expired.
Signed numeric strings now take the numeric branch, whose non-positive
guard already discards them.

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

* fix(stalker): reject out-of-range calendar components in expiry dates

The multi-argument Date constructor normalizes invalid components
('2026-00-00' becomes Nov 30, 2025), fabricating an expiry and countdown
from a placeholder. Round-trip the parsed year/month/day and reject any
date that does not survive unchanged.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 09:58:03 +02:00
4grayandClaude Fable 5 d44045de31 test(playlist): fix flaky refresh-preparation worker-event spec (#1331)
The spec raced a fixed 160ms sleep against the service's internal
rAF + 120ms paint delay that runs before deleteXtreamPlaylistContent
is called. Under parallel jest load the sleep could win, asserting
before the mocked worker event was ever delivered. Await a deferred
resolved by the mock right after it fires onEvent instead, so the
assertion is causally ordered after the signal update.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 22:00:29 +02:00
4grayandClaude Opus 5 94efd7d379 fix(workspace): restore playlist info entry in header playlist dropdown (#1328)
The context-actions section of the playlist switcher lost its "Playlist
info" button when playlist actions moved into the per-row menu
(156c12c51): the showPlaylistInfo input, the playlistInfoRequested
output and the whole shell wiring stayed alive, but no template rendered
the entry anymore — the active playlist's info dialog was only reachable
by locating its own row in the list.

Render the button again, gated on the existing showPlaylistInfo input,
alongside Account info and Add playlist. Regression test asserts all
three context actions render in the opened menu and that clicking
Playlist info emits.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:26:38 +02:00
4grayandClaude Fable 5 8f861a3a1b fix(ui): make the workspace usable on phone-sized screens (#1326)
* fix(ui): make the workspace usable on phone-sized screens

The shell was half-adapted below 640px: the rail flipped to a horizontal
bar but the link lists inside it kept stacking downwards, so the navigation
was drawn outside the bar and over the header (#1100).

Three resizable rails — the shell context panel, the live-layout channel
sidebar and the M3U channel drawer — kept their persisted desktop width,
which left the content around 50px on a 375px screen. They now span the
full width and stack above the content. The inline width written by
ResizableDirective is why these rules need `!important`.

Found while walking the rest of the UI at 375px and 768px:

- The detail hero kept poster and details side by side, squeezing the
  action row below its own labels until "Play" was clipped to its icon.
- The settings section list did not scroll and painted over the footer,
  which also affected short desktop windows.
- Hiding the M3U channel list on a phone was one-way: the restore handle
  was hidden and only Cmd/Ctrl+B could bring it back.
- The live header drew the channel count and the paginator on top of each
  other up to tablet width, because the paginator does not shrink and the
  meta collapsed to zero width and overflowed its box.
- The search scope checkbox was pushed off the right edge.

Live TV states a floor for the player instead of a ceiling for the lists,
so the video keeps a usable share of the screen under the categories panel
and the channel list.

Closes #1100

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

* fix(ui): address review — keep the palette reachable and the video visible

Two findings from the Codex review on #1326.

Hiding the command-palette trigger on phones removed the only pointer-driven
way to open it: the rail renders route links plus Settings and emits nothing,
so `commandPaletteRequested` had exactly one source. The button stays and its
keyboard-shortcut label is swapped for an icon instead. Doing that exposed a
latent flex trap in the same row — an <input> keeps an intrinsic min-width
from its `size`, and `min-width: auto` honours it, so the field refused to
shrink and pushed the trigger out onto the buttons beside it.

The M3U drawer released the shared player floor, which on a short landscape
phone (600-640px wide) left the content container at half the shell body.
The inline guide inside it is `flex: 0 0 <basis>` and took its full 180px out
of a container that no longer had it, so the video could reach zero height.
The floor is restored and now yields on short viewports, the video states its
own minimum, and the guide is what gives way.

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

* fix(ui): let the channel list keep its height on a landscape phone

Follow-up to the review: the player floor added in the previous commit was
measured against the viewport, not against what the shell had left. On a
640x360 landscape phone the stacked categories panel already takes 30vh, so
claiming another 50vh here drove the channel sidebar to zero height while it
was still marked expanded — no way to pick another channel — and pushed the
layout past the viewport.

The floor now applies only where the screen can afford it (`min-height:
600px`), the sidebar states a floor of its own so it cannot be squeezed out,
and the collapsed rule clears that floor so hiding the list still works.
Below that height the two panes simply share what is left.

Portrait is unchanged: categories 244px, channel list 220px, player 240px on
a 375x812 screen.

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

* fix(ui): address review — settings nav on landscape, poster dead space

Two more findings from the Codex review.

The stacked settings context panel capped itself at 30vh, which on a 360px
screen is 108px — less than the panel's own title and footer, so the seven
section rows collapsed to nothing behind an overlapping footer. On short
screens the caption gives way (the rail already labels the page), the footer
sheds its tall-screen padding, and the settings variant gets a slightly
larger cap: unlike the live routes there is no player below competing for
height, only a scrollable form.

The poster kept a 330px minimum from the skeleton fallback at the bottom of
the file — sized for the 220px desktop poster — while the stacked phone hero
renders it 140px wide with a ~210px aspect-ratio height. Every loaded detail
page carried ~120px of empty space between the poster and the title. The
override sits after that rule because it wins on source order, not
specificity.

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

* fix(ui): let the playlist switcher yield to the search field on narrow phones

Codex review of d6133da9: on a 320px header a route that contributes its
shortcut button left the search field less than its own chrome needs (~74px
of icon, palette trigger, gaps and padding), so the field's contents spilled
onto the buttons beside it.

The switcher is the one header region whose content can ellipsize, so it is
what shrinks — down to an 88px floor — while the field states its chrome as
a minimum. The field's basis moves from auto to zero so the input's intrinsic
size stops counting as content: with basis auto the field claimed its
intrinsic width even when room was ample and squeezed the switcher to ~115px
on a 375px screen that could fit all 140.

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

* fix(ui): fit the switcher's own chrome inside its phone floor

Follow-up to the Codex note that the trigger's fixed chrome (type icon,
refresh, chevron, gaps, padding) exceeds the 88px floor the shell now allows
the switcher to shrink to. The flagged scenario itself cannot occur — the
Multi-EPG shortcut needs Electron bridge methods the PWA lacks, and Electron
enforces a 900px minimum window width so it never sees the phone breakpoint —
but the floor should hold on its own terms rather than by accident of which
buttons happen to render. Dropping the decorative type icon on phones brings
the fixed chrome under the floor, and the name gets the space instead.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 19:23:20 +02:00
4gray 32ba209b63 fix(portals): restore fresh-import pins atomically (#1311)
* fix(portals): restore fresh-import pins atomically

* fix(portals): preserve Xtream restore retry state

* fix(portals): serialize Xtream restore revisions
2026-07-30 07:40:03 +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 f9e71a6d8d fix(electron): isolate refresh performance correlation 2026-07-26 22:37:52 +02:00
4grayandClaude Fable 5 b4c0cce741 fix(backup): export and restore hidden Xtream categories by real xtream IDs (#1224)
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>
2026-07-24 20:32:45 +02:00
4grayandClaude Fable 5 1c710d69ef chore(cleanup): delete nine dead components, orphaned i18n keys and unused deps (#1116)
* chore(cleanup): delete nine dead components, orphaned i18n keys and unused deps

Removes verified-dead components (0 class/selector references outside
their own files): EpgListComponent (+ epg-list-item), EpgViewComponent,
LiveEpgPanelComponent, StalkerCollectionChannelsListComponent,
NavigationComponent, FilterSortMenuComponent, video-player
ToolbarComponent, PortalCollectionShellComponent and
LoadingOverlayComponent, together with their barrel exports.

Alive code extracted from the deleted trees:
- LiveEpgPanelSummary -> libs/ui/shared-portals/src/lib/live-epg-panel-summary.ts
- EpgProgramActivationEvent -> libs/ui/epg/src/lib/epg-program-activation-event.ts
- epg-list.utils.ts trimmed to the three timeline-used helpers and moved
  to libs/ui/epg/src/lib/epg-program.utils.ts
- epg-item-description/ moved up out of the deleted epg-list/ folder

Also removes 18 i18n keys now unused (from all 18 locales), dead CSS
selectors targeting the deleted elements, and unused dependencies:
lodash (+ @types/lodash), semver, @ngrx/component-store and
@videojs/http-streaming (videojs-quality-selector-hls declares no peer
dependency on it; video.js 8 bundles VHS).

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

* chore(coverage): move shared-portals to Tier C, fix stale doc references

The Tier A gate failed in CI because deleting the dead epg-view and
live-epg-panel components removed the only specs in libs/ui/shared-portals.
The lib now contains a single type-only interface (LiveEpgPanelSummary),
so there is no runtime code to unit test; reclassify it to Tier C with a
documented reason, matching the gate's own guidance.

Also update remaining doc references to the deleted components in
docs/architecture/stalker-epg.md, iptvnator-ui-guidelines.md and
CLAUDE.md.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:09:50 +02:00
4gray e801028005 feat(epg): support playlist-scoped sources
Add playlist-scoped EPG source support for M3U playlists.
2026-06-21 22:53:07 +02:00
4gray 254879f92b fix(xtream): improve portal compatibility 2026-06-14 13:21:56 +02:00
4gray dfdb5bb8ad fix(playlist): save xtream details in pwa
- save Xtream playlist details through browser-safe metadata persistence in PWA\n- keep PWA Xtream data source cache in sync with current playlist metadata\n- cover dialog close timing, stale cache, and PWA data-source bootstrap regression
2026-06-13 16:04:54 +02:00
4grayand4gray e8aa7c3a34 [codex] Add scoped EPG security trust controls (#1054)
* Add scoped EPG security trust controls

* fix: address scoped trust review feedback

---------

Co-authored-by: 4gray <fourgray@proton.me>
2026-06-12 20:46:24 +02:00
4gray ed8680116c fix(runtime): address consolidated review feedback 2026-05-22 14:02:56 +03:00
4gray 4603bd83d5 refactor(runtime): gate playlist refresh by capability 2026-05-22 13:36:39 +03:00
4gray 6ad61ce680 refactor(playlist-ui): use runtime capabilities 2026-05-22 11:39:23 +03:00
4gray 820fbef121 refactor(playlists): move refresh action to ui boundary 2026-05-22 11:27:04 +03:00
4gray b2778d8b18 refactor(playlists): centralize delete lifecycle 2026-05-22 11:10:04 +03:00
4gray c571c57c5b refactor(runtime): centralize platform capabilities 2026-05-22 11:00:34 +03:00
4gray 36bce47764 merge: resolve master conflicts for pwa hardening
- merge origin/master into PR #964 and keep embedded MPV test on the isolated playback sub-entrypoint

- centralize EPG capability through DataService.supportsEpg and update PWA web-e2e expectations

- split BrowserAccessError copy between Electron and PWA diagnostics
2026-05-22 10:20:03 +03:00
4gray 4ab8915483 chore(web): enable strict TypeScript mode 2026-05-22 02:58:12 +03:00
4gray 55efc24608 fix(pwa): harden self-hosted runtime boundaries 2026-05-22 02:09:57 +03:00
4grayandClaude Opus 4.7 a27a29fcf2 refactor(ui): unify primary color to blue across all surfaces
Swap M3 primary palette from violet to azure so checkboxes, radio
buttons, raised CTAs, and active states read as the same blue used by
the rail selection token. Cascading template + SCSS updates align the
remaining hand-rolled surfaces (Add Playlist dialog, VOD play button,
radio player, multi-EPG, empty-state CTAs) with the unified system.

LIVE stays red (broadcast role), cyan stays on EPG "now" indicator,
green stays on completed-download — semantic colors keep their meaning;
only the indiscriminate accent uses get folded into the blue primary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:12:34 +02:00
4gray d24c77a143 chore(nx): enforce scoped workspace boundaries (#942) 2026-05-15 09:59:06 +02:00
4gray 2efdfea0c1 fix: preserve file paths for local playlist refresh
Refs https://github.com/4gray/iptvnator/issues/891

Entire-Checkpoint: f957cd9849e0
2026-05-04 10:06:39 +02:00
4grayandClaude Opus 4.7 880163ca7a perf(portal-status): hoist 30s TTL cache + in-flight dedup into shared service
PortalStatusService previously did one IPC + HTTPS round-trip per call,
forcing every consumer to roll its own cache (or, more often, not).
The result: opening the homepage rendered N playlist-item components
that each fired their own check, then opening the playlist switcher
fired N more for the same portals.

Move the cache and dedup into the service:

- 30 s TTL cache keyed by `${serverUrl}|${username}|${password}`. Same
  credentials = same cache entry, regardless of which playlist row
  triggered it.
- In-flight dedup via Map<key, Promise<PortalStatus>>. Two callers
  hitting the same portal in the same tick share one network request
  instead of racing.
- New `getCachedStatus()` for sync read (used by playlist-switcher to
  hydrate the UI on menu open without awaiting).
- New `clearStatusCache()` for log-out / debug flows.

Add `{ skipCache: true }` opt-out for the Xtream import dialog's
"Test Connection" button — that's a user-initiated check that must
return fresh truth, not a 30 s old cached result.

Net result: in the common flow (homepage → switcher), the switcher
opens with cached status indicators instantly. The single in-flight
dedup prevents the playlist-item ngOnInit + switcher onMenuOpened from
racing for the same portal.

Removed the component-local cache from playlist-switcher; service is
now the single source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: a635db375527
2026-05-02 00:57:08 +02:00
4grayandClaude Opus 4.7 888a2dd80a perf(playlist-switcher): stream Xtream portal status checks instead of blocking on Promise.all
Opening the playlist switcher fired one XTREAM_REQUEST IPC + HTTPS
round-trip per Xtream playlist and only updated the UI after ALL of
them resolved. A single slow or hung portal pinned every status dot
in the menu to the misleading red 'unavailable' state for the full
tail latency (often several seconds, sometimes longer).

Four changes land together:

1. Stream results — each portal's dot updates via signal.update() the
   moment ITS request resolves, independent of the slowest one. The
   previous Promise.all wrote a single Map at the end; now the Map
   grows incrementally.

2. New 'checking' status — extends PortalStatus with a pulsing-dot
   visual so users see "we're working on it" instead of red dots
   that look like failures. Respects prefers-reduced-motion.

3. 30-second TTL cache — opening, closing, and reopening the menu
   within 30s reuses prior status results and skips the IPC entirely.
   Cache survives across menu opens but is per-component instance
   (a global cache is a possible follow-up).

4. AbortController cancellation — closing the menu (or destroying the
   component) cancels in-flight checks so a slow portal can't write
   stale results into the next round. Solves the 'rapidly open/close
   the menu and watch dots flicker' problem.

The actual IPC layer wasn't changed — the wins come purely from
streaming, caching, and not lying to the user about portal state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 58a2d50756dd
2026-05-02 00:51:46 +02:00
4grayandClaude Opus 4.7 2f7406054d perf(ui): enable OnPush across hot list-rendering components
Audit found 10 list-rendering components (each >150 lines, all using
signal-based state) on default change detection. Default CD re-checks
every binding on every parent CD cycle (mouse moves, EPG progress
ticks, etc.); under OnPush these only re-check when their own signals
change or when explicitly markedForCheck.

All 10 use signals exclusively for state (no manual subscribe-and-mutate
patterns), so the conversion is straightforward and safe.

Components:
- recent-playlists (740L) — homepage playlist list with drag-drop
- vod-details-route (552L) — Xtream VOD detail page
- playlist-switcher (509L) — workspace shell playlist switcher
- season-container (444L) — Stalker/Xtream series episodes (DoCheck preserved)
- stalker-search (362L) — Stalker search results
- search-results (350L) — Xtream search results
- category-management-dialog (199L) — manage Xtream categories
- recently-added (162L) — Xtream recently-added rail
- category-content-view (Catalog) — category browse page
- grid-list (shared) — generic grid renderer used across portals

Tests: 124 passed across all touched lib projects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 2e1a36e2f0f6
2026-05-01 23:24:10 +02:00
4gray a6e886116c style(dialog): increase dialog width from 300px to 400px for better visibility
Entire-Checkpoint: c2cea9c530e6
2026-04-30 13:19:38 +02:00
4gray b1d519183f style(empty-state): enhance empty state component with new types and responsive design
- Expanded EmptyStateType to include 'welcome-dashboard', 'welcome-sources', 'no-data'.
- Introduced FeatureCard and SourceCard interfaces for better structure.
- Added FEATURE_CARDS and SOURCE_CARDS constants for feature and source card configurations.
- Updated empty-state.component.ts to handle new types and actions.
- Created responsive styles in empty-state.responsive.scss for better mobile support.
- Added theme styles in empty-state.themes.scss for light mode.
- Developed specific styles for welcome dashboard and sources in empty-state.welcome-dashboard.scss and empty-state.welcome-sources.scss.
- Updated recent-playlists.component.html to utilize new empty state types.
- Modified recent-playlists.component.ts to support new playlist actions.
- Introduced PlaylistFileImportService for handling playlist file imports.
- Updated downloads.component.html and downloads.component.ts to integrate new empty state handling.
- Enhanced unified-collection-page.component.html and unified-collection-page.component.ts to utilize empty state for no results.

Entire-Checkpoint: c6e522b4276c
2026-04-25 22:18:13 +02:00
4gray 6eb602d332 feat(playlist-switcher): add refresh button for active playlist with spinning icon
Entire-Checkpoint: c6e522b4276c
2026-04-25 22:14:20 +02:00
4gray 7caa18308d feat: enhance unified collection with workspace commands and backdrop handling
- Added WorkspaceViewCommandService to UnifiedCollectionPageComponent for workspace layout commands.
- Implemented clear current view command for favorites and recent items.
- Updated unified favorites data service to use buildXtreamCollectionUid for UID generation.
- Enhanced unified recent data service to normalize timestamps and handle backdrop URLs.
- Introduced withRecentItems feature to manage recent item additions with backdrop support.
- Updated VodDetailsRouteComponent and SerialDetailsComponent to backfill content backdrops.
- Modified IXtreamDataSource interface to include backdrop URL handling for favorites and recent items.
- Added tests for recent items feature to ensure correct functionality and backdrop handling.
2026-04-22 22:23:50 +02:00
4gray c8c93798f9 feat: enhance playlist backup export functionality and UI feedback
Entire-Checkpoint: c6e522b4276c
2026-04-21 08:54:03 +02:00
4gray e0f6b34aa0 style: update form field styles and dialog components
- Set default appearance for mat-form-field to 'outline' and dynamic subscript sizing in app.config.ts.
- Adjust dialog dimensions in workspace-shell-actions.service.ts for better responsiveness.
- Refactor settings.component.scss to remove specific mat-form-field font size overrides.
- Enhance global styles in m3-theme.scss for outlined inputs, ensuring consistency across the application.
- Modify workspace-command-palette styles to improve visual integration with the overall theme.
- Revamp add-playlist-dialog component to utilize segmented controls for category selection, improving UX.
- Update file-upload component to provide better feedback on file selection and drag-and-drop interactions.
- Clean up text and URL upload components by removing unnecessary placeholders and improving layout.
- Refine stalker-portal-import component by removing placeholder text for clarity.

Entire-Checkpoint: c6e522b4276c
2026-04-17 23:20:17 +02:00
4gray 542334a9a9 style: improve layout and responsiveness of playlist switcher component
Entire-Checkpoint: c6e522b4276c
2026-04-16 21:57:34 +02:00
4gray 1fca406486 style: enhance playlist switcher with search functionality and UI improvements
Entire-Checkpoint: 640047bcbeb2
2026-04-12 22:48:20 +02:00
4gray 156c12c51a feat: enhance playlist switcher with dynamic type filters and add playlist functionality
Entire-Checkpoint: 50668c5f4b12
2026-04-12 19:35:41 +02:00
4gray bd76d8456d style: update playlist switcher and header styles for improved UI/UX
Entire-Checkpoint: a0a3e00085b7
2026-04-12 19:23:25 +02:00
4gray c97673b928 refactor: refactor date handling across components to use date-fns and Intl.DateTimeFormat
- Replaced moment.js usage with Intl.DateTimeFormat for date formatting in various components.
- Introduced utility functions `normalizeDateLocale` and `formatWithIntl` for locale normalization and date formatting.
- Updated PlaylistSwitcher, RecentPlaylists, ContentCard, EPG components, and MultiEpgContainer to utilize the new date formatting approach.
- Removed moment-date pipe and related tests, simplifying date handling logic.
- Ensured all date displays respect the current locale settings.

Entire-Checkpoint: 271bd0162027
2026-04-11 10:41:33 +02:00