* feat(website): rebuild the hero, features, download and support sections
Third landing redesign PR: the home page now speaks the app's own
language instead of a template's.
- Hero: left-aligned headline, a primary button that follows the
visitor's OS (server-rendered fallback goes to /download/), an "All
platforms" button and a text link for self-hosting. Project numbers
(stars, downloads, languages, license) are set in the page's own
typography from the GitHub API at build time (`lib/github-stats.ts`);
anything the build cannot resolve is left out rather than faked. A
small "Now playing" card with fictional demo content replaces the
mascot, badges and social chips; the screenshot is lit by a blurred
copy of itself, like the player's ambient mode.
- Features: a bento of interface fragments (EPG rows with progress,
posters with a resume bar, a download queue with a REC dot, a remote
D-pad) instead of numbered cards with icon watermarks and a marquee.
The marquee CSS is gone.
- Download: one row per platform with the release's file names and a
"Detected" badge for the visitor's OS (`lib/detect-platform.ts`),
package-manager commands in the same panel.
- Support becomes a single row; the disclaimer moves into the footer,
which also gets the mascot and a link list.
- Home sections drop the decorative eyebrows and the divider rules; the
unused broadcast-wave, support-signal and watermark illustrations are
removed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(website): cover the rebuilt home sections and the OS-aware download CTA
`website-home-sections.test.mjs` (in the website test target) reads the
built home page — generic hero CTA to /download/, project facts, no
badge images or dashed borders, every feature page linked from the
bento, one download row per platform with release file names and the
Detected badge hidden, the disclaimer and mascot in the footer — and
then drives it in Chromium under Windows, macOS, Linux and Android user
agents: the primary button and the Detected row follow the platform,
the phone keeps the generic markup, and the copy button writes the
command to the clipboard.
`detectPlatform()` now consults the user-agent string before the
client-hints platform and the deprecated `navigator.platform`, so the
source a visitor's tools actually change decides first; silent sources
fall through instead of vetoing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): keep iPads on the generic download path
iPadOS asks for desktop sites with a Macintosh user agent and a
`MacIntel` platform, so the OS guess sent iPad visitors to the macOS
installers. A "mac" verdict is now trusted only on a device without
touch points: no Mac has a touch screen, every iPad reports several.
The browser test adds an iPad-in-desktop-mode row to the generic-device
matrix next to the Android phone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* fix(migration): recover legacy desktop sources without replacing current data
* test(migration): cover legacy recovery IPC contracts
* test(migration): use static legacy Electron bootstrap
* feat(website): turn the screenshot showcase into a channel switcher
Second landing redesign PR. "See it in action" was a tab strip in a
dashed frame showing one screenshot inside a drawn window chrome that
duplicated the chrome already in the shot. It is now a channel list:
number, screen name and one line about what the screen is for on the
left, a single frame on the right, and a caption linking to the matching
feature page.
- Channels advance on their own with a progress hairline under the
active row; hovering, focusing or scrolling the block out of view
pauses it and `prefers-reduced-motion` disables autoplay entirely.
- A brief on-screen "CH 03" badge confirms every switch.
- Arrow keys, Home and End move between channels; the list is a proper
vertical tablist with roving tabindex, panels carry `aria-hidden`.
- Six screens: Dashboard, Live TV, Program guide, Movies & series,
Downloads, Settings. Same files from `public/screenshots`; the
add-playlist shot is replaced by the movie detail and the download
manager.
- On phones the screen comes first and the list follows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(website): cover the channel switcher, keep focus pausing after mouseleave
Hover and focus now pause autoplay independently: clicking a channel
focused it, but moving the pointer away resumed the timer and seven
seconds later the selection moved under a still-focused tab.
New `website-screenshot-showcase.test.mjs` (in the website test target):
a structural half over the built HTML (vertical tablist, roving
tabindex, one aria-hidden panel per channel) and a browser half that
serves the build and drives it in Chromium — autoplay advances, hover
pauses, click + mouseleave keeps the pause while focused, arrow/Home/End
keys move selection, focus, panel, caption and badge together, blur
resumes, and `prefers-reduced-motion` disables autoplay. The browser
half falls back to the system Chrome channel and skips when no Chromium
exists, so the structural checks run everywhere.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* perf(website): lazy-load every showcase screenshot
The block sits below the hero and the feature grid, so an eager first
frame only competed with above-the-fold assets for visitors who never
scroll to it. Native look-ahead loading brings it in well before the
switcher is on screen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(website): share the browser harness, fail in CI without Chromium
`website-browser-support.mjs` owns the loopback static server and the
Chromium launcher for the website browser tests. The server resolves
every request against the build root and answers 404 for anything that
escapes it (CodeQL js/path-injection on the previous inline copy). The
launcher tries the Playwright download, then the system Chrome and
Chromium channels; when none launches it returns null locally so the
structural half still runs, and throws under `CI` so the interaction
assertions can never turn into a silent skip on the runner.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* perf(website): defer inactive showcase frames, document the browser tests
The six frames are stacked with opacity, so native lazy loading treated
all of them as near-viewport and fetched every screenshot at once. Only
the first frame now ships with a `src`; the switcher assigns it from
`data-src` when a channel is shown and preloads the one after it, so at
most two frames are ever in flight. The showcase test asserts the
markup and the runtime behaviour.
The website README now describes the browser-dependent suites, the
shared harness, and the skip-locally / fail-in-CI rule.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs(website): describe only the suite this PR adds
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): resume the switcher from where it paused, keep DOM order on phones
The dwell clock now stops while the switcher is hovered or focused and
the pause time is added back on resume, so the progress hairline
continues from its frozen position instead of snapping to zero and
granting a fresh seven seconds. The test asserts the resume.
On phones the list stays before the screen in the DOM and on screen
(tabs before their panels, focus order equals reading order); it hides
the per-channel descriptions and the keyboard hint there so the screen
stays close instead of being reordered with `order-first`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): never crop a showcase frame
The screen box stretches to the channel list's row height, and with
`object-cover` a wide screenshot lost its right side just above the
`lg` breakpoint. Frames are now contained on a dark stage (a letterbox,
as on a TV), and the per-channel descriptions are hidden between `lg`
and `xl` so the row stays close to the frame's own height.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): pause the switcher dwell while offscreen instead of resetting it
Leaving the viewport is now a pause like hover and focus: the frame
loop stops, the progress hairline keeps its width, and the clock
resumes from the same mark when the block scrolls back in. Only a
channel change resets the dwell. The interaction test scrolls away and
back to assert it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* perf(website): no next-frame prefetch when reduced motion disables autoplay
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(website): persistent pause control for the channel switcher
Hover and focus only pause while they last, which is no use to touch or
screen-reader visitors, so the list footer now carries a Pause/Resume
toggle (`aria-pressed`, full `aria-label`) that keeps autoplay stopped
until pressed again (WCAG 2.2.2). It is removed under reduced motion,
where nothing advances.
The interaction test now waits for the seven-second rollover and checks
that tab, panel, caption, badge and the preloaded frame all move to
channel 02, and that the toggle holds through mouseleave and blur.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): keep the tablist to its tabs, no successor prefetch when autoplay is off
The channel list's header and the Pause/Resume control sat inside the
`role="tablist"` container; the tablist now wraps only the six tabs so
assistive technology reads the toggle as an ordinary button beside the
list. `show()` preloads the next frame only while autoplay can reach it
(neither reduced motion nor the toggle has stopped it). Tests assert
both.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): preload the successor when auto-advance resumes
A channel picked while the switcher is paused deliberately skips its
successor; resuming now fetches that frame so the next automatic switch
does not land on a blank screen. The interaction test covers
pause → manual selection → resume.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): Resume restarts autoplay at once; same-channel progress in the test
The transient hover/focus pauses now watch only the channel list and the
screen. The footer with the Pause/Resume control is not one of those
regions, so after pressing Resume — with the pointer and the focus still
on the button — autoplay visibly restarts instead of waiting for the
visitor to leave the whole block.
The interaction test compares progress within one channel (a paused
Movies before and after Resume) rather than across channels, which
could fail on a slow runner, and asserts that autoplay runs while the
toggle keeps focus and hover.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* perf(website): one scheduler for the switcher, no frames while paused
Every pause reason (hover, focus, the toggle, leaving the viewport) now
goes through a single `sync()`: the animation-frame loop runs only while
the block is visible and nothing pauses it, and is cancelled otherwise,
so a switcher left paused schedules no frames at all. The dwell clock
still resumes from where it froze, and the successor frame is fetched
only when the loop actually starts — so scrolling away and back under a
persistent pause loads nothing. Tests count scheduled frames while
paused and cover pause → manual selection → offscreen → return.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): a channel picked while paused gets its full dwell after Resume
`show()` reset the pause mark, so the time a visitor spent paused after
picking a channel counted toward that channel's dwell and Resume could
advance immediately. When the loop is not running the new channel now
starts out paused at that moment. The interaction test asserts the
progress is still near zero right after Resume.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): follow reduced-motion changes and hidden tabs in the switcher
The reduced-motion preference is read from a live MediaQueryList: when
it changes while the page is open the scheduler stops or restarts and
the Pause/Resume control is hidden or shown (it is hidden, not removed,
for that reason). A hidden document counts as a pause too — background
tabs throttle animation frames while the clock keeps running, so
without it the first frame back would skip a channel. The interaction
test flips both at runtime.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): plain action button for the switcher pause, no manual animations under reduced motion
`aria-pressed` on a button whose name changes between "Pause
auto-advance" and "Resume auto-advance" announced the wrong thing; the
control is now an ordinary action button whose name says what pressing
it does next. The OSD slide and the panel fade get
`motion-reduce:transition-none`, so a manual selection under reduced
motion moves nothing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Tags were decorative: a plain span on every card and post header, with no
page, filter or search behind them, and a vocabulary of 32 slugs across 16
posts where 22 slugs appeared once. They now form a closed vocabulary of ten
topics (src/lib/blog-tags.ts) that the content collection schema enforces, so
an unknown slug fails the build instead of minting a new tag.
Every used tag gets a hub at /blog/tag/<tag>/ with CollectionPage and
BreadcrumbList structured data; the blog index and the hubs show a "Topics"
rail with post counts; and every chip links to its hub. The cards become
<article> elements with the title link stretched over the card, because chip
links cannot nest inside a card that is one big <a>.
Posts are retagged to the new vocabulary. A structural test covers the rail,
each hub, the chip targets, the sitemap and the absence of nested anchors.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The blog card kept the date and every tag in one non-wrapping flex row, so a
post with five or six tags overflowed the card frame and the date broke across
three lines inside the three-column grid. The date now sits on its own
non-wrapping line, tags wrap below it, and only the first three render as chips
while the rest collapse into a "+N" chip whose tooltip lists them. The featured
card still shows every tag.
The homepage rendered two cards under the featured post and left the third grid
column empty; it now shows three.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Publish /download/docker/ so "Self-host it with Docker" no longer sends
visitors off-site to the developer README. The page covers what the
image contains, a four-step quick start with the repository compose
command and an image-only compose snippet, the environment variables
and port that matter, the published tag patterns with the update
command, what the browser version leaves out, and a seven-question FAQ.
It links to docker/README.md for the full reference, to the desktop vs
browser comparison and to the setup guides; the README links back.
The download hub gains a fourth card, the homepage and hub links point
at the page, the platform switcher shows a Docker card on the OS pages,
and the comparison page links both the page and the README. The page
emits SoftwareApplication / FAQPage / BreadcrumbList JSON-LD and is
covered by website-download-pages.test.mjs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(playback): apply themes to player and EPG panels
* test(playback): verify active recording icon theme
* fix(epg): keep loading shimmer visible in both themes
* fix(playback): close legacy picture-in-picture on video replacement
* test(playback): wait for the selected video before PiP setup
* test(playback): await changed settings before PiP navigation
* fix(playback): release legacy WebKit picture-in-picture
* style(website): solid hairlines, Bricolage Grotesque display face, drop TV effects
First of three landing redesign PRs. This one only touches tokens and
surface treatment so every page (home, blog, /features, /compare,
/download) benefits without any structural change:
- Replace every dashed border with a solid hairline; `.card-dotted`
becomes `.card-panel` (bg step + hairline, lighter hairline on hover)
and the section divider is solid `surface-800`.
- Swap the display face from Playfair Display to Bricolage Grotesque
(DM Sans body and IBM Plex Mono unchanged). The italic accent word
that every heading repeated is gone; only page-level h1s keep the
accent, and as colour rather than italic.
- Remove the CRT/TV effects: the `.btn-tv` block and its nine keyframes,
the scanline overlay on the hero screenshot, the CRT hover overlay and
teal glow shadows on feature cards, the pulsing mascot, and the unused
scan/tv-* Tailwind animations.
- The hero download button and the header Download link are now solid
accent buttons; the self-host link is a plain text link.
- Blog tag chips use neutral surface borders so teal stays reserved for
actions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* style(website): finish the dashed sweep in prose and package rows
The blanket `border-dashed ` removal ate the utility but not the
variant prefix in the blog prose classes, leaving
`prose-pre:prose-pre:...` and `prose-hr:prose-hr:...`; and the
Linux package rows still used `divide-dashed`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Publish /compare/ with three pages answering the choices the app asks
users to make: M3U vs Xtream Codes vs Stalker portal, the built-in web
players vs embedded MPV vs external MPV/VLC, and the desktop app vs the
self-hosted browser version. Each opens with a one-paragraph verdict,
carries a feature matrix whose cells are yes, no or a qualifier, and
emits WebPage / FAQPage / BreadcrumbList JSON-LD — not
SoftwareApplication, because guidance is not a product listing.
These compare IPTVnator's own options against each other rather than
naming other products, so every cell is checkable against this
repository. Pages that name competitors remain phase 3's open half; the
plan now records what has to be decided first.
A registry in src/lib/comparisons.ts drives the hub, the switcher and
the tests. The header gains a Compare entry and the features hub links
across. tools/testing/website-compare-pages.test.mjs checks canonical
URLs, the verdict block, the table, schema, cross-links and sitemap
entries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The native-view addons and the external MPV launch path already run with
`ytdl=no`, but the frame-copy helper left mpv's default, so a refused HTTP
open fell through to ytdl_hook and yt-dlp before failing. That delayed the
`error` transition the auto-reconnect policy waits for and could leave the
session error reading "youtube-dl failed: unexpected error occurred"
instead of the load error.
Set `ytdl=no` in the helper's built-in block, before the stdin
`mpv-options` loop, so a user `ytdl=yes` session option still overrides
it. Document the helper's built-in block next to the other session-option
transports and add a release note.
Verified by rebuilding the helper against Homebrew libmpv and driving the
binary by hand against a connection-refused URL: the built-in default logs
only the ffmpeg/stream errors, while a `ytdl=yes` stdin line spawns yt-dlp.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Adds a global EPG display-time offset (Settings → EPG, whole minutes, ±720) for guides whose provider labels programme times with the wrong timezone. Display-only: parsed XMLTV values, SQLite rows, catch-up URLs and recording snapshots keep the provider's own times, so changing it needs no guide refresh. Closes the global part of #50.
The contract lives in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` with two equivalent forms: `epgDisplayTimeMs` shifts a programme for display, `epgProviderClockMs` shifts "now" into the provider's clock for every "currently airing" decision — the batched `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs`, and the channel lists, the Xtream/Stalker previews, the M3U player's current-programme mirror, the unified collection resolver, the dashboard live cards and the recording overlap all pick the same programme the guide renders as "now". Portal short-EPG windows start at the provider's own "now", so under a non-zero offset the Xtream preview surfaces cut their window from the full guide at the provider clock, Stalker short-EPG requests are widened for negative offsets, and every per-stream memory of the previous offset is retired together when the setting changes.
Co-authored-by: Mark Jardine <markjardine27@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* fix(playback): seek Embedded MPV steps relative to mpv's own position
Arrow keys and the ±10 s buttons in the Embedded MPV player advanced only
about a second per press when pressed repeatedly or held. The shortcuts
already asked for 5 s steps, but `EmbeddedMpvCommandRunner.seekBy` turned
each step into an absolute `seek` computed from `session.positionSeconds`,
which is floored to whole seconds, polled every 500 ms (helper snapshots at
most every 250 ms) and not refreshed by the seek reply. Every press inside
that window therefore landed on the same target.
Steps now go through a new `EMBEDDED_MPV_SEEK_BY` IPC / `seekEmbeddedMpvBy`
bridge method that every backend forwards as mpv `seek <delta>
relative+exact`: `seekBy` exports in the macOS addon and the Windows/Linux
`wid` addon (Linux over its JSON IPC socket), and a `seek-by` stdin command
in the frame-copy helper. mpv resolves the delta against its own position
and merges queued relative seeks, so presses accumulate as in mpv itself.
The absolute form survives only as a fallback for a preload without the
method or an addon binary without `seekBy`; the timeline scrub still
commits an absolute target.
Validated with a real mpv 0.39 IPC probe: three relative seeks in a burst
advance +15 s, three absolute seeks from one stale base advance +5 s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(playback): drop speculative position update from relative Embedded MPV seeks
Review follow-up for the relative seek path.
The macOS and Windows/Linux `seekBy` exports advanced `snapshot.positionSeconds`
by the delta after dispatching the mpv command. That is not idempotent the way
the absolute seek's optimistic write is: the observer (mpv event thread, or
the Linux IPC poll) can already have stored the post-seek `time-pos` under the
same mutex, so adding the delta on top counted the step twice, and while paused
nothing corrected it. On Linux it also advertised a position that a failed
socket delivery never reached. Relative steps now leave the snapshot alone;
only the observed `time-pos` updates the position.
The packaged Linux frame-copy smoke now drives `seekEmbeddedMpvBy` through the
built app: a burst of three +2 s steps issued without waiting for snapshots has
to land on 6 s, and a -60 s step has to clamp at 0. The generated Y4M fixture
grows from 2 s to 12 s (about 415 KB) so the burst and the playing section that
follows stay inside the clip. Replayed against a local mpv 0.39 with the same
fixture and media server: burst -> 6.0, -60 -> 0.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs(agents): mirror the Embedded MPV relative-seek contract into AGENTS.md
Review follow-up: the Shared Player Controls section documents the frame-copy
commands and shortcuts, so the relative seekEmbeddedMpvBy invariant lives
there too, next to the CLAUDE.md note.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(playback): reject a Linux relative seek the mpv IPC socket did not accept
Review follow-up: the Linux branch of SeekBy discarded the socket transaction
result and returned normally, so a step that never reached mpv looked like a
seek still awaiting observation. It now throws like a failed mpv_command_async
on the in-process engines; the renderer swallows the rejection and resyncs
from the next snapshot, and the main process logs it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Publish /features/ with one landing page per feature people search for:
M3U playlist player, Xtream Codes player, Stalker portal player, TV
guide (EPG) and phone remote control. Each page composes a feature hero
(download and setup-guide calls to action) with the download-page
sections, carries SoftwareApplication (featureList) / FAQPage /
BreadcrumbList structured data, links to the other feature pages and
the matching guides, and uses only mock-backed screenshots.
A registry in src/lib/features.ts drives the hub, the per-page
switcher, the homepage feature cards (now links) and the header
Features entry, so a new page is one registry entry and one .astro
file. tools/testing/website-feature-pages.test.mjs checks canonical
URLs, schema, cross-links, hub coverage and sitemap entries.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Publish "How to Load an M3U Playlist and Add an EPG in IPTVnator": the
three import methods plus drag-and-drop and OS file opening, the
playlist views, refresh and startup auto-update, attaching an XMLTV
guide through Settings or a url-tvg header, the tvg-id / tvg-name /
name matching order with manual mapping, catch-up attributes,
troubleshooting and a seven-question FAQ. The three guides now link to
each other, the download pages point at all three, and llms.txt lists
the new one.
Three guide shots join the manifest: the M3U URL dialog, the Groups
view (reusing open-m3u-groups under the guides group) and the EPG
settings section with a staged source row. A settings shot leaves the
form dirty, which arms the app's close guard and blocked app.close()
indefinitely; the capture now discards unsaved settings before every
action and before teardown, and bounds every locator wait.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up to #1516 for the vendor-chrome path (shared controls opted out). With
Video.js's own controls, Chromium leaves a clicked control-bar button focused,
and a focused Video.js component captures the keyboard entirely, so after
clicking fullscreen Space left fullscreen instead of pausing and the seek,
volume and mute keys did nothing until the user clicked the video. ArtPlayer
and the native HTML5 controls were verified unaffected.
The legacy Video.js chrome now releases the focus a pointer interaction leaves
on a control (vjs-pointer-focus-release.ts). The release is scoped to the
.vjs-control-bar and pointer-attributed, and runs on both focusin (focus
landing on a control, e.g. a menu handing focus to its button) and click (a
control clicked while already focused, which fires no focusin); keyboard Tab
focus and modal-dialog focus traps are preserved. The eligibility helper is
shared with ControlsSurface via pointer-focus-release.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Extra libmpv options (Settings > Playback) reach every embedded engine off the command line (createSession array on Windows/macOS, a 0600 --include file on Linux native-view, a stdin preamble for the frame-copy helper); the keys the embed depends on are refused, and keys libmpv rejects are reported once per session. Dropped streams reload automatically (error, or ended on live) with 2 s -> 30 s backoff, six attempts per outage and a 30 s stability reset, only for a load that already played; engine failures stay terminal, a running recording is filed as interrupted and restarted after the reload, and an external subtitle file is re-added. Settings.embeddedMpvAutoReconnect (default on) opts out; the player shows 'Reconnecting... attempt N of M'.
Started by Bpl5966 in #1515 and finished by the maintainers in the same PR.
Co-authored-by: Bpl5966 <amine.b1959@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Publish "How to Connect a Stalker or Ministra Portal to IPTVnator": the
portal URL shapes discovery accepts, MAC normalization, the optional
serial/device-ID/signature fields and the pinning rules behind the
"generate device IDs" toggle, what endpoint discovery does on Add, the
sections a portal source gets, Account info, and a troubleshooting list
built from the app's own refusal messages, plus a seven-question FAQ.
The guide is cross-linked from the download pages and llms.txt.
Guide screenshots come from the capture script. Shots that walk into a
Stalker portal start the stalker-mock-server and seed its marketing-demo
portal for that run only, so release shots never gain a third source
card. The frame guard allowlists exactly that scenario's MAC and keeps
rejecting every other MAC-shaped string.
To keep the live-TV frame free of third-party images, the fictional live
channel list and the channel-logo SVG renderer move into
@iptvnator/shared/marketing-fixtures; both mocks now serve
/assets/marketing/logo/<slug>.svg, the Stalker marketing-demo scenario
builds its ITV categories, channels and schedule from those fixtures
instead of faker names with picsum logos, and the mock resolves asset
URLs on get_all_channels too, which is the response the app renders
the channel list from.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The E2E added in #1517 proved the ITV case only. Radio shares the live
layout and the same context-panel handler, and its inline audio player is
gated on the store selection just like the ITV player, so a regression in
`onStalkerCategoryClicked` would silence a station the user never switched
away from. The new scenario mirrors the ITV one: play a station, pick
another category, wait for the sidebar title to change, and assert the
audio player is still mounted.
Closes#1521
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
`website:lint` failed on master since the per-OS download pages landed:
`src/lib/downloads.ts` imported the workspace root `package.json` through a
relative path, which `@nx/enforce-module-boundaries` rejects ("external
resources cannot be imported using a relative or absolute path").
The value is only needed as a build-time constant for the offline fallback,
so `astro.config.mjs` now reads the root manifest through the file system
and injects `__IPTVNATOR_VERSION__` via Vite `define`; `downloads.ts` uses
that constant and `src/env.d.ts` declares its type. Verified with
`WEBSITE_SKIP_RELEASE_FETCH=1`: the built download pages link the
`iptvnator-<version>` assets and no define literal leaks into the output.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Switching the Live TV or radio category in the shell context panel tore
down the Stalker player: `onStalkerCategoryClicked` cleared the selected
item for every section, and the live layout gates its player on
`selectedItem`. Xtream live (#936) and M3U groups already keep the channel
playing across a category/group switch.
- Context panel: return before `clearSelectedItem()` for `itv`/`radio`;
VOD/series clicks still drop the open detail before navigating.
- Live layout: the category-change reset effect no longer wipes the
playing channel's short-EPG fallback or cancels a fallback load in
flight; only a section change (itv <-> radio) does that now.
- Regression coverage in the context panel spec, the live layout spec and
a new web E2E scenario; docs and a `.changes/` note added.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Chromium focuses a clicked <button>, and a focused control captures the
keyboard: Space and Enter activate it again, and ControlsShortcuts yields
to any interactive element in the key's path. After a click on the
fullscreen button, Space left fullscreen instead of pausing and the seek,
volume and mute keys did nothing until a click on the video took focus
away. Follow-up to #1512, which stopped that focus from pinning the bar
but left it on the button.
A completed pointer click now releases the focus it left on the control
(onBarClick -> ControlsSurface.releasePointerFocus). The click is
attributed by its pointerType (empty for Enter/Space activation and
element.click()), with the legacy MouseEvent fallback answered once per
recorded press, so keyboard activation keeps focus where Tab put it.
Only buttons and range sliders are released. Chromium keeps its
sequential-focus starting point at the blurred control, so a later Tab
continues from it. The release dispatches a focusout while the pointer
still rests on the control, so the volume anchor ignores it instead of
closing the popover under the hovering mouse.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Publish "How to Add an Xtream Codes Account to IPTVnator" as the first
evergreen guide: what the server URL, username and password are, the
Add playlist flow with the connection test and its four verdicts, the
Auto-detect method for pasted provider messages, what the import syncs,
Account info, refresh, troubleshooting and a seven-question FAQ. The
guide is cross-linked from the three download pages and llms.txt.
Blog posts gain an optional `faq` frontmatter list: BlogPost.astro
renders it as an accordion after the body and emits FAQPage JSON-LD
next to the BlogPosting entry. LinkCards and PostButton keep internal
links in the same tab.
Guide screenshots come from the release capture script: manifest shots
may carry a `group`, `--group guides` captures only those into
apps/website/public/blog/guides/screenshots/, and a release run skips
them. New setup actions open the Add playlist dialog with the mock's
fictional Xtream credentials (connection test shown), the Auto-detect
method with a labeled hand-out, and the Xtream Live TV view. Dialog
helpers and fixture identities move into shared modules so the driver
and the navigation actions cannot import each other cyclically.
tools/testing/website-guides.test.mjs checks the FAQPage schema, the
download-hub link and the shipped screenshots of every guide;
screenshot-guards.test.mjs covers group validation and output routing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Settings > General gains "Window on startup" (normal / maximized /
fullscreen), Electron only, mirrored into the main-process config by
SETTINGS_UPDATE and applied at the next window creation. `--fullscreen`
forces one fullscreen launch (consumed by the first window). F11 toggles
OS-level fullscreen through WINDOW:TOGGLE_FULLSCREEN — the exit path on
Windows/Linux where the title bar is hidden — and is skipped while the
player owns document.fullscreenElement.
attachWindowStateEvents tracks native and HTML fullscreen as two flags,
since Electron leaves only the HTML state when the window was already
natively fullscreen. macOS ignores the constructor `fullscreen` option on a
hidden window, so ready-to-show repeats the request after show(). Toggles
are decided by an observe-only, event-fed tracker
(native-fullscreen-transitions.ts), never against isFullScreen().
Closes#1455
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Add /download/ with Windows, macOS and Linux landing pages: OS-specific
install steps, requirements, feature list, FAQ, related posts and a
platform switcher, plus SoftwareApplication / FAQPage / BreadcrumbList
JSON-LD on every page.
Direct asset links resolve the latest published release from the GitHub
Releases API at build time (asset names, sizes, publish date) and fall
back to the root package.json version when the API is unreachable;
WEBSITE_SKIP_RELEASE_FETCH=1 forces the fallback. The deploy workflow
passes GITHUB_TOKEN to the build. The homepage download cards and the
header Download link now point at the new pages, the homepage schema
reads the resolved version instead of a hard-coded 0.20.0, and the
locale count is corrected to 19.
tools/testing/website-download-pages.test.mjs checks titles, canonicals,
direct asset links, structured data, cross-links and sitemap entries of
the built output; nx test website runs it beside the Giscus test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The published post was the raw release-notes scaffold: 42 feature and 76 fix
bullets in area-prefixed inventory form, with the four highlight sections
buried after the feature list. Rework it into the v0.22 shape: a "What
changed" table, the four highlights first and expanded, themed
improved/fixed sections with one-line entries, and the long tail of fixes
under a Spoiler. Every PR/issue reference from the original is preserved;
open text drops from 6.7k to 3.4k words.
Spoiler.astro sits inside `not-prose`, so lists rendered without markers
and links in body colour; give its content list, link, strong and code
styling so grouped lists inside it read like the rest of the post.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* perf(database): commit catalog writes in row-budgeted transactions
Refreshing or deleting a large Xtream playlist spent most of its time in
"Removing cached content": every 100 rows were deleted in their own
transaction, so a 300k-row catalog cost ~3,000 commits, each flushing an
FTS5 segment, re-appending dirty index pages to the WAL and, about every
4 MB, running an fsync-ing auto-checkpoint. Measured on a 900k-row copy of
a real database the delete took 13 s where a single set-based statement
takes 3 s, with 2 GB of WAL traffic instead of 140 MB. The re-import wrote
its rows the same way.
Deletes now read content row counts per category from the covering
indexes, pack categories into groups of about 5,000 rows and issue one
set-based DELETE per group, then drop the categories (and, for playlist
removal, the user-data tables) with one scoped statement each. Inserts keep
100-row statements but commit fifty of them at a time. Cancellation still
lands between commits and progress still reports after each one; the
worker additionally throttles progress events to one per 100 ms with
summed increments, so a large operation no longer floods the renderer.
Same subset, same machine: 13.0 s -> 5.6 s for the delete, 16.7 s -> 8.6 s
for the insert; the fsync-bound share is larger on Windows and spinning
disks.
Closes#1292
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(database): pin the row budget and the worker-side progress flush
Review follow-up: the default 5,000-row budget and the 50-statement insert
commit were only exercised with explicit overrides or sub-budget inputs, and
nothing covered the worker controller flushing a coalesced progress report
before its terminal event. Both are now pinned, and the docs no longer claim
the insert path reports SQLite `changes` or binds 1,600 parameters per
statement (it binds the eleven columns a value supplies).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(e2e): keep the stress suites on 100-row commits via a test-only budget knob
The Xtream responsiveness and playlist-switcher suites slow the database
worker down with IPTVNATOR_DB_WORKER_BATCH_DELAY_MS so they can observe an
import mid-flight; the stress catalog is 1,920 rows per type, which at the
production budget of 5,000 rows per commit is a single commit per type and
too few progress events for their assertions. The new companion knob
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION restores 100-row commits for those
runs only; unset or invalid it leaves the default untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(database): scope refresh and cache-clear deletes to the captured category ids
The row-budgeted rewrite deleted a refreshed playlist's categories with a
playlist-wide predicate. The worker serves other requests between commits,
so a newer import of the same playlist could create categories in that
window and lose them to the older refresh, after which its content inserts
fail their foreign keys. Count and delete now use the ids the collection
step read, as the chunked code did; playlist removal keeps its playlist
scope because its final playlist-row delete cascades the same set.
deleteCategoriesWhere runs every filter through requireScopedFilter.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
WebPlayerViewComponent remounts the engine component for every playback
application, and the DOM Fullscreen API exits the moment its element leaves
the document. The fullscreen element was the engine shell, so every next-
episode click, autoplay hand-off, channel zap and alternative-source switch
dropped the viewer back to the page.
app-player-controls gains a `fullscreenTarget` input; HTML5, Video.js,
ArtPlayer and Embedded MPV forward it, and WebPlayerViewComponent passes its
own host element, which spans all applications of one mount. Keeping
fullscreen exposed a latent bug: the Electron header handoff set plain
fields under OnPush hosts and was only rendered thanks to the fullscreen
exit's stage resize; `channel`/`vjsOptions` are signals now.
Covered by unit regressions (fullscreen target, WebPlayerView remount, OnPush
handoff), a web-e2e run through a manual and an automatic episode switch, and
a manual Electron check. Docs and release note updated.
Closes#1498
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(dashboard): detail-first continue watching cards with quick actions (#1441)
Continue Watching cards now open the detail page on click like movie
cards; resuming the saved episode, marking it watched, and removing the
entry from history move into a per-card ⋮ menu. Series details land on
the earliest season with unwatched episodes (or the latest once all are
watched) instead of always season 1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): address review findings and season auto-select regressions
- A session's own watched toggles no longer re-resolve the selected
season when the positions map first fills — marking season 1 watched
used to jump the view to season 2 (CI regression in the web and
Electron season-watched-toggle E2Es).
- The all-watched season fallback skips loaded-but-empty seasons and
picks the latest season that has episodes (Greptile P1).
- Mark as Watched uses the strict failure-propagating save boundary
(Codex P2), and both card mutations surface persistence failures via
a snackbar with the new WORKSPACE.DASHBOARD.ACTION_FAILED key in all
19 languages (Greptile P2).
- Season E2Es now assert the intended post-reload behavior: the fresh
mount lands on the earliest unwatched season while season 1 keeps its
watched state behind its tab.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(downloads): track live-TV recordings in the download manager
Embedded MPV recordings were written to disk and forgotten: no list, no
reveal/play, no missing-file handling, and the channel/EPG context was lost
the moment the recording stopped. Recordings now live beside downloads:
- New `recordings` table (no unique index, no playlist FK — recordings
survive source deletion; playlist name stored via playlistDisplayLabel).
- EmbeddedMpvRecordingTracker persists the lifecycle: start/stop hooks plus
a session-snapshot observer for implicit stops (stream-replacement
auto-stop, frame-copy helper crash, session error/close); startup repair
turns rows a hard kill left behind into playable `interrupted` partials.
- Channel/EPG metadata is captured at recording START in all four live
hosts (M3U, Xtream, Stalker ITV, unified live tab); a clean stop triggers
renderer-side enrichment with every program overlapping the recorded
window, keyed by target path — covering recordings that span a program
boundary. Provider EPG never reaches SQLite, so post-hoc lookup is
impossible by design.
- Own RECORDINGS_* IPC surface + RECORDINGS_UPDATE_EVENT ping and a
separate supportsRecordings capability gate (the supportsDownloads
allowlist is all-or-nothing and stays untouched). Reveal/play shell IPCs
are gated on the recordings table, so the renderer-supplied recording
directory stays a write-location preference, not a shell-access grant.
- Manager UI: `recording` filter chip, "Recording now" queue section (REC
pulse, elapsed, live file size — no percentage, the length is unknown),
16:9 channel-logo Recordings library, Needs attention with Remove only,
focused detail at /workspace/downloads/recording/:recordingId.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): close the stop-enrichment race and repair player stubs
Greptile spotted a real ordering bug: the stop IPC returns as soon as mpv
acknowledges, while the recording row's terminal-state update is still queued
in the tracker. The renderer answers that snapshot with stop enrichment, whose
handler only accepts a terminal row — so the covered-program metadata could be
silently dropped with "Recording not found".
- EmbeddedMpvRecordingTracker.whenSettled() exposes the serialized write
chain; RECORDINGS_UPDATE_PROGRAMS awaits it before the terminal-row lookup.
Regression covered from both sides: the handler must not touch the database
until the barrier resolves, and the barrier must imply a committed row.
CI also caught spec stubs that had not learned the new player inputs (my local
run-many had been an Nx cache hit, so the failures only surfaced in CI):
- Teach the `app-web-player-view` and `app-embedded-mpv-player` stubs the
`recordingMetadata` input and `recordingStopped` output across the m3u,
Xtream, Stalker, unified-live-tab and web-player-view specs.
- The races spec now asserts the metadata argument explicitly instead of
matching a two-argument call.
- Extract the Stalker and unified-live-tab spec stubs into sibling
`*.spec-stubs.ts` files (the pattern ui/playback already uses) so both specs
stay under the 1200-line test limit without shaving assertions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(downloads): make the recordings events spec a module
The spec deliberately has no static imports — every dependency is swapped
through jest.doMock before the harness's dynamic import — which also made it a
TS script rather than a module, so its top-level `registeredHandlers` landed in
the global scope and collided with the same-named const in stream-probe.spec.ts
(TS2451). Local per-project runs compile the specs separately and stayed green;
only the Tier A coverage suite builds them into one program, so CI caught it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): address Codex review on recording lifecycle
Four findings from the Codex review, all real:
- P1: `addon.stopRecording()` only dispatches — native-view uses
`mpv_set_property_async`, frame-copy writes a helper command — so
finalizing inside the stop hook could stat a file mpv had not flushed and
even unlink bytes still being written. The tracker now treats the hook as a
request and finalizes on the acknowledged inactive snapshot, with a 10 s
bound so a lost acknowledgement cannot strand the row. Only a recording
that never went active has its empty reservation removed. Stop enrichment
follows through `whenFinalized(targetPath)` (bounded) instead of merely
draining the write queue.
- Live file size: `file_size_bytes` is written at finalization only, so the
manager's 15 s refresh reported nothing while recording. Active rows are
now decorated with a current `fs.stat` size.
- Manager-initiated Stop bypassed both player stop paths, so recordings
spanning program boundaries kept only the start-time program.
`EmbeddedMpvPlayerComponent` now owns the active→inactive edge and emits
`recordingStopped` for every trigger; the adapter and legacy toggle no
longer emit it themselves.
- Startup recovery could terminate a row another live instance was still
writing under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES. Rows carry `owner_pid`
and recovery skips those whose owner process is alive.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): derive the enrichment wait from the stop fallback
Greptile caught the seam my previous fix left: the enrichment barrier waited
5 s while the tracker's acknowledgement fallback only finalizes at 10 s, so a
stop mpv never confirms let the terminal-row lookup expire early and drop the
covered programs with no retry — precisely the case the fallback exists for.
The wait is now derived from the acknowledgement bound (fallback + 1 s), with
a regression test that fails if the two ever drift apart again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): address the second Codex pass on recordings
Four more findings, all real:
- P1 (macOS native-view): `StopRecording` clears `recordingActive` *before*
dispatching the async property set and restores it if the request is
rejected, so the first inactive snapshot is optimistic, not an
acknowledgement — the tracker could finalize (and stat) a file mpv was
still writing, and a rejected stop would leave the row `completed` while
recording continued. An inactive snapshot now has to survive a 1.5 s settle
window (three poll cycles); a revived recording cancels the pending
finalization.
- Removing a failed row unlinked its path unconditionally, which takes the
file of a newer recording that reused the freed name within the same
timestamp second. The cleanup now runs only while no other row claims it.
- The All chip and the header's active badge ignored recordings, so a manager
holding only recordings read "All 0" and an active recording never showed
up in the badge.
- Switching channels auto-stops the recording, but by the time the host
handled the stop its `activeChannel`/EPG already described the NEW channel,
so the old recording was enriched with the wrong schedule (and an unrelated
program could be promoted to its title). The stop event now carries the EPG
key captured while the recording was active and every host compares it
before enriching.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): close the persistence race and two recording UX gaps
- Greptile P1: the enrichment deadline (fallback + 1 s) still raced the
terminal write — if the tracker queue or the UPDATE took longer than the
remaining margin, `whenFinalized` returned while the row was still
`recording` and the one-shot enrichment was dropped. The deadline now
bounds only the wait for mpv; `finalize()` removes the entry synchronously,
so once it has started the wait follows the write itself.
- Codex: `RECORDINGS_STOP` ignored `owner_pid`. Session ids restart per
process, so under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES stopping another
instance's row could stop an unrelated local recording. Foreign rows are
now refused.
- Codex: the In progress chip counted active recordings while its filter
deliberately hid them, so clicking it showed "no matches". Active
recordings now belong to that filter — a chip whose count disagrees with
its page is a lie.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(downloads): drop the enrichment barrier instead of tuning it
Three review rounds circled the same class: synchronizing mpv's asynchronous
stop acknowledgement with a one-shot program enrichment. Each fix moved the
deadline (5 s → fallback+1 s → wait-on-the-write) without removing the reason
a deadline existed at all — the handler insisted on a *terminal* row.
It never needed one. `openSync('wx')` makes the reserved path exclusive while
a recording owns it, so the newest row for that path IS the recording that was
stopped, and `finalize()` writes only status/end time/size and never
`programs_json`. Enrichment and finalization are therefore order-independent:
- `RECORDINGS_UPDATE_PROGRAMS` matches the newest row for the path in any
status and awaits only the tracker's write queue, which exists solely to
guarantee the INSERT committed (a recording stopped milliseconds after it
started).
- `whenFinalized`, its deadline constant, and the per-entry finalized promise
are gone; the tracker keeps only the settle window and fallback that make
*finalization* itself correct.
No behavior is lost and the whole timing class disappears with the code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): bind recording finalization to its entry and shield live rows from startup repair
Two races from the Codex review:
- Tracker timers finalized by reusable session id, so a stop followed by an
immediate restart on the same session let the old settle timer finalize
the NEW row (marked completed while mpv kept writing) and strand the old
row in 'recording'. Finalization is now bound to the exact open entry,
and replacing a session's entry arms the old entry's settle timer so an
unobserved stop still finalizes it.
- reconcileStaleRecordings() runs after the renderer is interactive; a
recording started during bootstrap has ownerPid === process.pid and was
repaired to interrupted/failed mid-write. Recovery now skips rows the
tracker reports as actively tracked (activeRowIds()).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): harden recording startup repair against recycled pids and stale renderer lists
Second Codex pass on the recovery path:
- A live ownerPid alone no longer shields a row: after a crash the OS can
recycle the pid for an unrelated process, which would park the row in
'recording' with no instance able to finalize it. Recovery now also
checks (best-effort, ps/tasklist) that the process looks like an
IPTVnator/Electron instance; an unreadable name stays conservative and
keeps the skip.
- The renderer loads before the repair pass runs and may already hold the
pre-repair list with a stale Stop affordance; recovery now broadcasts
one RECORDINGS_UPDATE_EVENT after changing any rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): defer teardown finalization behind the flush window and bound the live-size stat
Third Codex pass:
- A synthetic error/closed snapshot from disposeSession() arrives while
the frame-copy helper may still be flushing (0.5 s quit grace + 2 s
SIGTERM grace before SIGKILL). Finalizing there statted a file mid-write
— short captures became terminal 'failed', longer rows persisted a
truncated size, and startup recovery could repair neither. The tracker
now defers that finalization behind a 2.5 s flush window; the row stays
'recording' (repairable) meanwhile, and an already-acknowledged stop's
settle timer keeps its 'completed' verdict instead of being relabelled
'interrupted'.
- The active row's live file size used a bare await stat(): one stat
hanging on a dead network filesystem wedged every RECORDINGS_GET_LIST.
The probe now mirrors the availability probe's contract — in-flight
coalescing plus a 1 s deadline degrading to no size.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): unmask recycled recording owners, guard the PWA recording route, and unblock file probes
Fourth Codex pass:
- Recycled-pid discrimination no longer stops at the process-name family
check (any Electron app could shield the row): a live holder must also
not provably have started after the recording did (ps -o etime= /
PowerShell StartTime). A pid frees only when its previous owner dies, so
a recycled pid's holder is always younger than the recording; unreadable
evidence stays conservative.
- /workspace/downloads/recording/:recordingId gets a supportsRecordings
capability guard redirecting the PWA to the manager — RecordingsService
never becomes authoritative there, so the detail rendered a permanently
blank workspace.
- Finalization and startup repair stat through a bounded async probe (3 s
deadline, ENOENT/ENOTDIR as the only proof of absence) instead of
main-thread statSync: a dead network mount no longer freezes the main
thread or the tracker queue, repair leaves unjudgeable rows recoverable,
and finalization keeps the requested status with an unknown size rather
than branding a likely-good file failed. The 0-byte reservation unlink
is fire-and-forget for the same reason.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): keep inconclusive recording probes out of Needs attention and bound repair batches
Fifth Codex pass:
- Recording list decoration now uses the bounded availability variant that
preserves 'unknown': a timed-out or permission-errored probe is not
proof of absence, so a good recording on a slow mount no longer lands in
Needs attention with its Play/Reveal hidden.
ElectronRecordingItem.fileAvailability widens accordingly; consumers
already gate on === 'missing'.
- Startup repair probes its whole batch concurrently, so main.ts awaits
roughly one 3 s deadline instead of one per stale row.
Cross-process ping propagation under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES
stays out of scope (debug-only flag, same single-window design as
DOWNLOADS_UPDATE_EVENT) — rationale left on the review thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): fix duration rounding at hour boundaries and bound owner-process probes
Sixth Codex pass:
- The recording duration formatter rounded minutes after flooring hours,
so 59:45 read '60 min' and 1:59:45 read '1 h 60 min'. One shared
recordingDurationLabel() now rounds the total minutes before splitting
(both the detail page and the library card used a duplicated copy).
- Startup repair's synchronous ps/tasklist/PowerShell ownership probes get
a 2 s spawn timeout and are memoized per unique pid, so a batch of rows
from one crashed instance costs at most one name query and one
start-time query, and a hung process query degrades to the conservative
fallback instead of blocking the main thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): return to the manager through history from the recording detail
Seventh Codex pass (single finding): with a validated returnUrl the manager
is already the previous history entry, so Back now uses Location.back()
instead of pushing a third entry that made the browser Back button reopen
the detail; router navigation remains the fallback for direct links —
matching the offline-detail navigation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): bound removal cleanup and shell gates, date interrupted rows by file mtime
Eighth Codex pass:
- RECORDINGS_REMOVE no longer awaits an unbounded unlink of a failed
row's leftover reservation: cleanup is raced against the 1 s deadline,
so a hung network unlink cannot keep the Remove action busy — the row
deletion is what matters.
- Reveal/Play swap the synchronous lstat gate for the bounded async
availability probe: a dead mount no longer blocks the main process, and
only PROVEN absence refuses the action — an inconclusive probe lets the
shell try and answer honestly.
- Startup repair dates an interrupted row's endedAt from the captured
file's mtime (mpv's last write) instead of the repair time, so an
overnight shutdown no longer inflates a five-minute capture into an
hours-long recording; the repair-time fallback remains when mtime is
unreadable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): keep recording-start program metadata fresh across EPG boundaries
Ninth Codex pass (single finding): the unified live tab's
recordingMetadata computed cached its Date.now() verdict — starting a
recording after an EPG boundary snapshotted the previous show. It now
tracks the existing 30 s progress tick. The Stalker live layout's
currentProgram had the same memoization (feeding recording metadata, the
EPG panel summary, and external-player metadata); it gains a 30 s clock
tick with interval cleanup in ngOnDestroy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): re-select the Xtream current program against the 30 s tick at recording start
Tenth Codex pass (single finding): the Xtream live layout's recording
snapshot read withEpg().currentEpgItem, a computed whose Date.now()
verdict stays cached until epgItems changes — a recording started after
an EPG boundary snapshotted the previous show. The selection logic is
extracted as the pure findCurrentEpgItem(items, nowMs), the store
computed delegates to it unchanged, and recordingMetadata re-selects
with the layout's existing 30 s currentTimeMs tick.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): scope stop enrichment to the exact recorded list item
Eleventh Codex pass (single finding): the stop-enrichment guard compared
only the EPG key, which is not unique for M3U items — two list entries
sharing a tvgId (or the display-name fallback) could hand the first
item's recording the second item's schedule after a switch-triggered
auto-stop. RecordingStartMetadata/RecordingStoppedEvent gain an opaque
sourceItemKey (unified tab: item.uid; M3U player: channel.id), captured
while the recording is active exactly like the EPG key, carried through
the player's stop edge, and compared by the hosts before enriching.
Xtream/Stalker keys are already playlist+id-scoped and need no extra key.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): derive the M3U start-snapshot program from the active channel's schedule
Twelfth Codex pass (single finding): the M3U recording snapshot read the
NgRx currentEpgProgram, which retains its last value across a channel
switch and through EPG gaps (the mirror effect only dispatches when a
program exists) — a recording started on a channel with no airing
program could persist the previous channel's title, which stop
enrichment deliberately never overwrites. The snapshot now derives the
program from the active channel's own schedule against the existing 30 s
clock, and an EPG gap snapshots no program.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): keep finalizing rows in the recovery ledger and guard the repair update
Thirteenth Codex pass (single finding): finalize() removes an entry from
the open map before its queued terminal update commits, so
activeRowIds() briefly omitted a row still persisted as 'recording' —
startup recovery overlapping a clean stop could relabel it interrupted,
after which the tracker's status-guarded update could not restore
'completed'. Finalizing entries now stay in a dedicated ledger until the
update settles, and the repair UPDATE itself is guarded on
status='recording' as a second belt against a finalization that commits
between recovery's SELECT and its write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): register update listeners before the initial list load
Fourteenth Codex pass (single finding): RecordingsService awaited its
initial RECORDINGS_GET_LIST before subscribing to the update ping — a
recording transition during that request pinged into the void while the
response still reflected the pre-transition state, and recording pings
are rare enough that nothing self-healed until the 15 s poll (armed only
once an active row is visible). The listener now registers first so the
load-state coalescing queues the trailing refresh. DownloadsService had
the same latent window and gets the same reorder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 4gray <fourgray@proton.me>
* feat(playback): add quality selection to shared player controls
Adds a per-session video quality menu (Auto + "1080p"-style levels) to the
shared player-controls layer, mirroring the audio-track pattern:
- Contract: qualityLevels capability, qualityLevels/qualityAutoEnabled state,
setQualityLevel command with AUTO_QUALITY_LEVEL_ID (-1) restoring ABR.
- hls.js (HTML5/ArtPlayer via the neutral source bridge): levels with
list-index ids, smooth switching through nextLevel, selection read from
manualLevel; refresh events extended with MANIFEST_PARSED, LEVELS_UPDATED,
LEVEL_SWITCHED.
- Shaka (DASH): variant tracks filtered to the active audio language, ABR
disabled before selectVariantTrack; manual state keyed to the exact player
instance so a session restart never shows a stale selection.
- Video.js: new VjsQualityLevels over videojs-contrib-quality-levels (manual =
exactly one enabled level, auto = all enabled, derived statelessly).
- Embedded MPV and external players report the capability false.
The capability derives from the manifest (advertised only for >1 video
rendition), nothing persists to Settings, and the menu rides the default-off
webPlayerSharedControls rollout gate. Labels come from one shared helper so
all engines render the same vocabulary. QUALITY/QUALITY_AUTO keys added to
all 19 i18n files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): pin DASH quality candidates to the active audio stream
Review findings on #1470:
- Shaka quality candidates now match the active variant's exact audioId
(language fallback only when Shaka reports none), so a DASH manifest with
same-language audio tracks (main vs. commentary, stereo vs. 5.1) can no
longer switch the audio track or show duplicate levels when a quality is
picked. Regression test added.
- Mirror the quality-selection contract into AGENTS.md's Shared Player
Controls section, which must stay in sync with CLAUDE.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): track Video.js manual quality intent explicitly
Codex re-review finding on #1470: VHS flips a rendition's `enabled` flag off
itself when it temporarily excludes failing renditions, so inferring the
manual/auto mode from the enabled count could report a manual selection the
user never made once exclusions leave a single survivor.
VjsQualityLevels now records the picked level object as explicit manual
intent: error exclusions read as auto, a picked level that leaves the list
reverts to auto, and the bridge resets the intent on every new source.
Regression tests added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): re-enable surviving renditions when the picked level is removed
Codex follow-up on #1470: dropping manual intent when the picked
QualityLevel leaves the list reverted the UI to auto but left the surviving
renditions disabled by the earlier manual pick, pinning VHS with no
selectable rendition. Reverting to auto now re-enables every remaining
level, both on the removal event and lazily from the state read.
Regression tests added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The public demo deployment is paused for good, so the production CLIENT_URL
fallback allowed an origin nobody can reach — a manual (non-Docker) self-host
failed every provider request with a CORS error. Default to the documented
http://localhost:4333 instead, and point og:url at the project website.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>