The home-sections browser test clicked `.copy-btn` and immediately asserted
`textContent() === 'Copied'`. The button flips its label only after the
asynchronous `navigator.clipboard.writeText` promise resolves, which is after
Playwright's `click()` has already returned, so a loaded CI runner sometimes
still read "Copy" (PR #1619, run 35369544094).
Poll for the label with Playwright's `expect(locator).toHaveText` (bounded
5 s) before reading the clipboard. The import is dynamic and sits after
`launchBrowser()`, so the local no-Chromium skip path is unchanged.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* feat(updater): nightly builds and a stable/nightly update channel
Every master push publishes its artifacts as a prerelease of
4gray/iptvnator-nightly instead of the rolling test-master draft, with a
version of <next patch>-nightly.<commit date>.<run number> applied in
every build job. Settings → About gains an Update channel switch;
AppUpdateService re-points electron-updater per check (feed repository,
allowPrerelease, channel name, allowDowngrade reset) and reads release
notes from the repository the requested version belongs to. Channel
switches are forward-only: a nightly build stays until a newer stable
release exists.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(updater): compute the nightly version once and keep re-runs safe
Review follow-ups: the nightly version is resolved by a leading job and
handed to every build job, and the patch is bumped only when the base
tag already exists so the release-cut window stays below the imminent
release. A re-run never deletes a published nightly; only a draft left
by a failed run is replaced. Typed update-status literals in the
remaining specs carry the new channel fields.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(packaging): expect the nightly-version prerequisite in the build workflow graph
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* 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
Every page pulled its three typefaces from fonts.googleapis.com and
fonts.gstatic.com, each blog post fetched the author avatar from
githubusercontent.com, and the giscus client script ran on page load. That is
four outside origins contacted before a reader does anything, each costing a
DNS lookup and a TLS handshake on the critical path.
Fonts now come from the @fontsource packages the app already uses and are
emitted as .woff2 beside the site; the avatar is a 3 KB file in public/; and
the giscus embed is created by a "Show comments" button that carries the
configuration as data attributes, so the script is only built when a reader
asks for it.
A delivered page now makes no third-party request at all, verified across the
whole build. The variable Bricolage package names itself "Bricolage Grotesque
Variable", so that exact name leads the display stack in the Tailwind config.
The giscus test now checks the button configuration and asserts the client
script is absent from the delivered HTML.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"TiviMate for PC" is the query behind most of this page's traffic, so the
page now names the apps people mean instead of describing a category. What
it says about them is what could actually be verified: TiviMate's Google
Play listing (Android and Android TV, reads M3U, Xtream Codes and Stalker,
"a media player that provides no content"), and IPTV Smarters publishing
Windows and macOS builds — which is why the page does not claim TV apps
have no desktop version.
The useful half is the part no affiliate site writes: these names are
heavily abused, the stores carry copycat listings, and much of the search
result for any of them is someone selling "subscriptions" under the app's
brand. The developers are player authors who do not sell channels, which is
exactly what IPTVnator says about itself — the page links to our own
unofficial-websites post for the same reason.
Three FAQ entries answer the query directly, and the page joins the
NAMES_THIRD_PARTY_SOFTWARE set so the test requires its dated disclaimer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third page in the set, and the one that answers the question people ask
before installing anything: where should IPTV actually run. It compares the
two places rather than two products — everything that needs a keyboard
(adding a provider, attaching a guide, mapping a channel, working out why a
stream fails) against everything that needs a couch — and states plainly
that IPTVnator has no TV build, with the phone remote as the answer for a
laptop wired to the television.
No third-party software is named, so the page carries no ThirdPartyNote: the
rows describe the ordinary experience of each kind of device and say so in
the caption.
The compare hub's lead no longer claims every page is a choice inside the
app, and the three newest entries get distinct icons.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second page under the third-party rules. Kodi is a media center that reaches
IPTV through a PVR add-on, so the comparison is about shape rather than
score: where the source comes in, what each program is built for, and the
platforms. The tables say plainly what Kodi does that IPTVnator does not —
a local library with scraped metadata, add-ons and skins, a remote-friendly
interface and builds for TV boxes and a Raspberry Pi — and the verdict tells
a reader already running Kodi on a TV to stay there.
Kodi's IPTV capabilities come from whichever PVR client is installed, so
rows that depend on one say "Depends on the add-on" instead of claiming
something this page cannot verify.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First comparison page that names other software. It answers the question
every "IPTV on a computer" guide raises — VLC opens the playlist, so why a
dedicated player — with three tables (what each can connect to, what a
playlist file cannot carry, and the practical differences) and eight FAQ
entries, then ends where the honest answer is: IPTVnator hands VLC the
stream, with the playlist's headers and the resume position, and reads the
position back.
Naming another project brings rules, now recorded in the registry doc
comment and a new README section: a dated ThirdPartyNote stating the other
project is independent and endorses nothing, only stable publicly documented
facts, no claim of a missing feature that was not checked, and no logos,
brand styling or links to the other project. The compare test enforces the
note and the date for every page in its NAMES_THIRD_PARTY_SOFTWARE set.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
New guide at /blog/tmdb-metadata-guide/: a sent/not-sent table for the
opt-in enrichment (title, year, app language and the build's API key leave
the machine; nothing about the user, the provider or the playlist does),
how to switch it on, when to use your own free key, what the feature
unlocks, and what the local cache holds. Eight FAQ entries, and a section
for readers who would rather leave it off.
The post states plainly that TMDB is a metadata database and not a content
source, and carries TMDB's required attribution through a reusable
TmdbAttribution component (their logo plus "This product uses the TMDB API
but is not endorsed or certified by TMDB."), the same wording the app shows
in Settings and About.
Its screenshot is the settings section itself: the capture's G5 guard keeps
enrichment disabled for the whole run, so no licensed poster or still can
reach a published frame. The new action stages the switch in the form only,
which reveals the key field, the cache panel and the attribution while the
stored setting stays off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
New post at /blog/epg-wrong-program-fix/: a table separating the three
symptoms (an empty row, a channel showing another station's schedule, and
every programme shifted by a fixed amount), how the tvg-id → tvg-name → name
lookup chain produces the first two, the manual "Map EPG channel" flow, and
the display-only EPG time offset with the sign to use. Seven FAQ entries.
The three screenshots are mock-backed. The Xtream mock gains /demo/guide.xml,
an XMLTV guide for its marketing live channels whose ids deliberately match no
playlist tvg-id, which is what makes the manual mapping worth showing; the
capture imports it through the settings, accepting the private-network
confirmation a loopback source raises, then right-clicks a channel of the M3U
fixture and searches the dialog. The new actions live in
capture-navigation-epg-actions.ts, alongside the setup, portal and download
modules, and borrow one entry point from each of its two neighbours instead of
duplicating their navigation.
Also fixes assertMockServerIdentity: it checked both expected categories
against get_vod_categories, but "Urban Drama" is a series category, so the
reuse path rejected every already-running mock and a capture could only run
when the port happened to be free. Each category is now checked against its
own endpoint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(xtream): render catch-up start times in the panel timezone
The `{Y-m-d:H-M}` segment of an Xtream timeshift URL is read by the panel
with `strtotime()` in ITS timezone (`server_info.timezone`), never the
viewer's. The timezone was learned in memory only, by the store's
`checkPortalStatus()`, so the Favorites / Recent catch-up resolver — which
reads the STORED playlist row — always fell back to the viewer's local
clock and asked the panel for the wrong programme (#1562).
- Normalize the panel's clock once (`resolveXtreamServerTimezone`): an
ICU-resolvable name is kept, otherwise a `UTC±HH:MM` offset is derived
from the `time_now` / `timestamp_now` clock pair, so spellings such as
`UTC+3` no longer silently mean "local time".
- Persist it on the playlist row through `transformPlaylistMeta` (no-op
when unchanged) and project it back from the payload in
`DB_GET_PLAYLIST`, so both catch-up entry points and a restart see it.
- Format with `hourCycle: 'h23'` (server midnight is `00`, never `24`) and
read timestamp-less EPG `start`/`end` strings in the panel's clock.
- Mock: `tzoffset:tzoffset` scenario with an unusable timezone name and a
+03:00 clock pair; Electron e2e covers Live TV, Favorites, a restart into
Global favorites, and the clock-pair derivation at a UTC-3 viewer.
Closes#1562
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): guard the account-info answer by playlist identity and reject rolled-over dates
Review follow-ups (Greptile):
- A source switch while `get_account_info` is in flight no longer hands
playlist A's status or clock to playlist B: the store is patched only
while the asking playlist is still selected, the timezone is persisted
under the asking playlist's id regardless, and a late failure cannot mark
the newly selected playlist unavailable.
- `parseNaiveUtcMs` reads the constructed date back, so out-of-range panel
strings (`2026-13-01 25:00:00`) are rejected instead of silently rolling
over into a real instant.
- Document that a clock-derived fixed offset is a DST-less snapshot, refreshed
by every account-info check and only ever used for non-standard servers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): drop a panel clock that no longer belongs to the source
Review follow-ups (Codex + Greptile):
- A metadata update or DB_UPDATE_PLAYLIST that points the source at another
server drops the persisted `serverTimezone` (payload-only) until the next
account-info check, so Favorites / Recent cannot keep rendering the OLD
panel's clock; an update that supplies a clock keeps it.
- A late account-info answer is persisted only onto a row that still points
at the panel it came from — an edit that moved the source during the
request keeps the clock the edit flow dropped.
- The PWA data source and the route-session converter carry the persisted
timezone into the store playlist, so a later response without a usable
clock has a previous value to preserve.
- Mirror the catch-up timezone contract into AGENTS.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): drop the stale panel clock inside the UPDATE statement
Review follow-up (Codex): the database worker interleaves requests, so a
read-modify-write of the playlist payload could hand a concurrent upsert's
newer payload back to the past. The `serverTimezone` removal on a server
URL change is now one `CASE … json_remove(payload, '$.serverTimezone')`
expression inside the same UPDATE, guarded by `json_valid`; the spec runs
the real statement against Electron's SQLite on the actual `playlists`
table (moved, renamed, clock-less, malformed-payload and NULL-URL rows).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(xtream): split the server-clock primitives out of the timezone util
Review follow-up (Greptile): `xtream-server-timezone.util.ts` had grown past
the 300-line file guideline. The zone-agnostic wall-clock primitives (stored
forms, Intl parts, naive parsing) now live in `xtream-server-clock.util.ts`;
the timezone util keeps the Xtream policy and re-exports the public helpers,
so every import and the spec are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): offer the learned panel clock to storage on every check
Review follow-up (Codex): a transient storage failure left the clock in the
store but not on the row, and the next check compared the answer with the
in-memory value and never retried. The resolved timezone is now always
handed to `transformPlaylistMeta`, whose row-level equality check keeps the
common case a read without a write; a failed write is retried by the next
account-info check.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): apply an account-info answer only to the panel it came from
Review follow-up (Codex): an in-place edit keeps the playlist id while
moving the source, so an answer already on the wire for the OLD panel
passed the id-only guard and patched the new panel's status and clock into
the store. One `answersFor(candidate, credentials)` predicate now gates the
store patch, the error path and the persisted-row transform alike.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): never report another panel's status for the selected playlist
Review follow-up (Greptile): callers gate content initialization on the
value `checkPortalStatus()` returns for whatever is selected NOW. When the
answer no longer describes the selected playlist (source switch or in-place
edit during the request), the store's own verdict about the current
selection is returned instead of the old panel's status — on success and on
failure alike.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): persist the panel clock with one conditional UPDATE
Review follow-up (Codex): `transformPlaylistMeta` reads the row and then
upserts it whole, while the Xtream edit dialog saves through
`DB_UPDATE_PLAYLIST` outside `PlaylistsService`'s queue and the database
worker interleaves requests — an edit landing between that read and the
upsert was silently undone.
Persistence now goes through `IXtreamDataSource.rememberServerTimezone`:
- Electron: new `DB_SET_PLAYLIST_SERVER_TIMEZONE` worker op — one UPDATE
that `json_set`s the payload only while the row still points at the
request's connection and does not already carry the value; a malformed
payload is never rewritten (CASE, not AND, so json_extract cannot run
before json_valid). Wired through the worker types, main handler,
preload, bridge interface, both IPC contract tables and
`DatabaseService.setXtreamPlaylistServerTimezone`.
- PWA: `transformPlaylistMeta`, whose read and write share one IndexedDB
readwrite cursor transaction, plus the localStorage copy.
The store no longer injects `PlaylistsService`; it offers the resolved clock
to the data source and keeps only its in-memory guards. Real-SQLite coverage
for the op (fresh / same / moved / NULL / malformed / missing rows),
delegation specs for both data sources, docs updated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): keep the stored panel clock across clockless full upserts
Review follow-up (Codex): a `PlaylistsService` mutation that read the row
before `DB_SET_PLAYLIST_SERVER_TIMEZONE` landed and upserted afterwards
replaced the payload with its clockless snapshot. `DB_UPSERT_APP_PLAYLIST(S)`
now carry the STORED clock into a snapshot that has none while the row still
points at the same connection (`playlistConflictUpdate`, nested CASE so the
json_* readers never run on a malformed payload); a snapshot with its own
clock, or one that moves the source, wins as is. Real-SQLite coverage for
kept / moved / own-clock / batch rows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(release): split capture-navigation under the max-lines cap
`tools/release/capture-navigation.ts` had grown to 567 counted lines, past
the 400-line rule, which failed `release-tools:lint` and — because the file
was not in the baseline — the max-lines baseline test on master and on
every PR branched from it. The 19 named setup actions are now grouped by
subject over one leaf module of shared page helpers:
- `capture-navigation-helpers.ts`: playlist-id registry, dialog handling,
navigation moves, `settleUi`
- `capture-navigation-setup-actions.ts`: add-playlist dialogs, settings
sections, remote control
- `capture-navigation-portal-actions.ts`: portal catalogs, live lists,
alternative sources (the two identical live-category flows share one
helper)
- `capture-navigation-download-actions.ts`: the download manager shots
- `capture-navigation.ts`: the `runAction` dispatcher, theme switching and
the re-exported API the seeding driver and the capture script import
Actions call their siblings directly instead of recursing through
`runAction`, so no module depends on the dispatcher. The action vocabulary
is unchanged (same 19 names, same waits and timeouts); every file is under
300 lines and the new modules are listed in the `release-tools` lint target.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(electron): move the panel-clock SQL into its own operations module
Review follow-up (Greptile): the timezone persistence, invalidation,
upsert-preservation and row projection had landed in
`playlist.operations.ts`, a baselined 1,000-line file. They now live in
`playlist-server-timezone.operations.ts` (155 lines) — the three SQL
shapes plus the payload projection — and the playlist operations compose
them; the baselined file shrinks by 107 lines. Behaviour and the
real-SQLite coverage are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
New guide at /blog/remote-control-guide/: enabling the remote in Settings,
opening it on a phone from the QR code, what each control does and which
list it navigates, a checklist for a page that does not load, and why the
remote must stay on the local network. Eight FAQ entries. The remote-control
feature page now links to it instead of the M3U guide.
Both screenshots are mock-backed. The phone view is the first "browser" shot:
a manifest entry names a loopback URL and a mobile viewport, and the capture
frames it in a separate Chromium page behind the same network and content
guards, with the manifest validator accepting loopback origins only. The
setup saves the remote-control setting so the app's own server answers, then
selects a live channel; the Xtream mock's marketing scenario now serves live
stream URLs from local bytes so that selection never leaves the machine.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
New guide at /blog/alternative-sources-guide/: where the Sources chip comes
from (a local lookup across the reader's own Xtream playlists, never a search
outside them), how to read fact tags versus ~guesses, check availability,
switch playlists mid-film without losing the timecode, pin a preferred copy
per movie, and what the opt-in auto-switch does and on which players. Eight
FAQ entries, opening with the general ContentDisclaimer.
The two screenshots are mock-backed: the Xtream mock gains a marketing2
scenario that serves the identical marketing catalog under a second
credential pair (with a spec proving the catalogs match), the capture seeds it
as a "Fictional Xtream Backup" source only for shots that walk into it, opens
its category so the movies reach the local content cache that discovery reads,
and two new setup actions open the chip and the checked popover.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>