The renderer was reading session.id and forwarding it to the addon, but
during the loading window that id is the placeholder
"embedded-mpv-starting" set by createLoadingSession. If the user adjusted
volume, seeked, or toggled audio/subtitle/speed/aspect before the addon's
createSession returned the real id, that placeholder id reached the
addon — and the addon's getSessionOrThrow threw a raw std::runtime_error
which libc++abi terminated the process on.
Two fixes, defense in depth:
1. Renderer (session controller): use the canonical sessionId() signal,
which is null until the addon hands back a real id, as the gate for all
IPC calls. Wrap every IPC call in a guardIpc helper that swallows
addon-side throws so a torn-down session or race won't surface as an
uncaught promise rejection.
2. Native (embedded_mpv.mm): change getSessionOrThrow to take a
Napi::Env and throw Napi::Error::New(env, ...) instead of
std::runtime_error. node-addon-api converts Napi::Error to a JS
exception cleanly; the previous std::runtime_error escaped the C++
frame and aborted the process when the addon was built without
NAPI_CPP_EXCEPTIONS translation. Refactor splits findSession (returns
nullptr) from getSessionOrThrow (env-aware) so call paths that just
probe a session's existence don't pay the throw cost.
The native fix needs an addon rebuild to take effect; the renderer fix
prevents the crash trigger immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
Native addon (apps/electron-backend/native/src/embedded_mpv.mm)
- Extend SessionSnapshot with subtitleTracks, selectedSubtitleTrackId,
playbackSpeed, aspectOverride.
- Refactor track parsing into a shared updateTracksFromNode helper that
filters by mpv "type" so audio and subtitle tracks share the code path.
- Observe sid, speed, video-aspect-override; clear sub state on
MPV_EVENT_START_FILE.
- Export setSubtitleTrack (handles trackId === -1 as "no" to disable),
setSpeed (clamped to 0.25–4.0), setAspect (passthrough string for
video-aspect-override).
- Snapshot output now includes the new fields.
Service (embedded-mpv-native.service.ts) + IPC + preload
- Mirror methods on EmbeddedMpvNativeService with capability detection: each
method throws a descriptive error if the loaded addon doesn't expose the
underlying native function (i.e. user is running an older build).
- New IPC channels EMBEDDED_MPV_SET_SUBTITLE_TRACK, _SET_SPEED, _SET_ASPECT
registered in events file and exposed via preload.
- Extend EmbeddedMpvSupport with a capabilities probe so the renderer can
hide controls for features the current addon build doesn't ship.
Renderer (embedded-mpv-player.component.{ts,html})
- Three new popovers anchored above their buttons (subtitle / speed /
aspect), gated by capabilities() and (for subtitles) by track count.
- Subtitle popover includes an Off entry; speed/aspect use fixed presets.
- Error state now surfaces the same overlay as the stalled state, with a
Retry button that bumps the existing retryNonce signal — covers #8 from
the audit.
- All session-payload defaults (loading stub, error stub, refresh fallback,
dispose payload, native createSession default) updated for the new
required fields.
NOTE: Existing addon binaries do not expose the new methods. Until the
addon is rebuilt (pnpm run serve:backend:embedded-mpv or the release
build script), capabilities will report subtitles/playbackSpeed/
aspectOverride as false and the new buttons will simply not appear.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
VLC was unconditionally spawned per click — VLC's own single-instance
preference fails because the per-launch RC args defeat its D-Bus
forwarder. Mirror the existing MPV reuse pattern so users can opt in to
driving one tracked VLC via its RC interface (clear + add) instead of
opening a new window every stream.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
The embedded MPV player renders via libmpv into a custom Cocoa view, which
bypasses mpv's built-in screensaver inhibition. Hold an Electron
powerSaveBlocker (prevent-display-sleep) while any session is playing and
release it on pause, dispose, or shutdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
fixPath() was called as the very first statement at module load
(top-level, before app.setName, before app.whenReady), which on
macOS/Linux spawns an interactive login shell — bash/zsh -ilc 'env' —
and waits SYNCHRONOUSLY for it to print the environment back. With
oh-my-zsh / heavy .bashrc setups this is routinely 50-300ms blocking
the Electron main process before window creation can even begin.
The only purpose of fixPath in this app is to populate process.env.PATH
so that subsequently-spawned external player binaries (MPV/VLC) can be
resolved by bare name. That's a user-action path (clicking play with
external player configured), not a startup-critical one. Two callers
exist (player.events.ts) and both fall back to bare 'mpv' / 'vlc' only
after checking well-known absolute paths.
Move the call into a setImmediate scheduled at the END of
bootstrapAppEvents — after DB init, IPC handler registration, and
window load. The user-visible startup sequence no longer carries the
shell-spawn cost. By the time anyone could plausibly click an external
player, PATH is already hydrated.
Idempotent + Windows-gated (fix-path is a no-op on Windows anyway).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
Two independent fixes that compound: the dashboard now renders progressively
as each rail's data resolves, and the slowest of those rails (Xtream
recently-added) no longer scans the entire content table.
Per-rail skeletons (template restructure):
The old @if (!ready()) gate hid the whole dashboard until ALL FOUR loading
flags resolved (playlistsLoaded, globalRecentLoaded, globalFavoritesLoaded,
xtreamRecentlyAddedLoaded). The slowest one pinned the entire skeleton up
for the full tail latency — visibly seconds of "loading" even when 3 of 4
rails could have rendered immediately.
Replace with per-rail conditionals:
- Hero: renders the moment globalRecentItems[0] is available; skeleton
shows only while data.globalRecentLoading() is true and no item exists.
- Each rail: shows real content if its cards are non-empty, its own
skeleton if its dedicated loading flag is true, nothing otherwise.
- The Xtream recently-added rail's skeleton is gated on having Xtream
playlists at all, so M3U-only users never see a skeleton for it.
The loading signals were already exposed on DashboardDataService
(globalRecentLoading, globalFavoritesLoading, xtreamRecentlyAddedLoading)
but went unused because of the monolithic gate. Same skeleton markup is
reused per rail; no styling changes.
Drop CAST(added AS INTEGER) in getGlobalRecentlyAdded:
The query ordered by sql<number>\`CAST(content.added AS INTEGER)\`. SQLite
cannot use an index on a column wrapped in a function, so the existing
idx_content_type_added index was bypassed and the planner did a full table
scan + sort on content (10k–100k+ rows for a typical Xtream catalog) on
every dashboard load.
Sort by schema.content.added directly. Xtream stores Unix-epoch timestamps
as 10-digit numeric strings (anything since 2001-09-09), so lexicographic
and numeric sort are equivalent. The (type, added) index now drives the
ORDER BY too — full table scan becomes an index range scan + LIMIT 20.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 1e3392724bc8
Drizzle's .set() expects a column-typed value or an SQL fragment, not a
bare Placeholder. The earlier prepared-statement refactor (313230ab)
passed sql.placeholder('position') directly, which compiled clean under
ts-jest's isolated-modules mode but failed the full type check during
nx build:
TS2322: Type 'Placeholder<"position", any>' is not assignable to
type 'number | SQL<unknown> | SQLiteColumn<...>'
Wrap the placeholder in sql<number>\`...\` so it resolves to SQL<number>
at compile time. Behavior at runtime is identical — the placeholder is
still bound at execute() time per chunk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 746d41da84ff
Channel-list rendering called EpgService.getCurrentProgramsForChannels(),
which forkJoined N getChannelPrograms() Observables — each firing its own
IPC round-trip and its own SQL query. For a 500-channel visible window on
first scroll, that was 500 IPC calls and 500 SELECTs hammering the EPG
table.
Add GET_CURRENT_PROGRAMS_BATCH IPC handler that takes the channel-id
array and runs a single SELECT with WHERE channel_id IN (...) AND
start <= now AND stop >= now. The renderer-side cache and TTL behavior
are preserved; only the network of IPC calls collapses to one. A
fallback path keeps the old per-channel behavior if the preload lacks
the new endpoint.
Per-channel display-name fallback (NOCASE id, then NOCASE display name)
is preserved from handleGetChannelPrograms so behavior matches the
existing single-channel handler.
Inspired by matracey/iptvnator@d25a7e8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 6719280e397b
Clearing all recents of a given content type fired N concurrent IPC calls,
each opening its own implicit transaction in the recently_viewed table.
For users with hundreds of recently-watched VOD/series rows this added
real overhead even though the UI updates optimistically.
Add a new DB_REMOVE_RECENT_ITEMS_BATCH path end-to-end:
- removeRecentItemsBatch() Drizzle op: one transaction, one prepared
statement reused per row
- Wire through worker → IPC handler → preload → window.electron typings
→ DatabaseService
- UnifiedRecentDataService.removeRecentItemsBatch() groups items by
source. Xtream items go through the new batch IPC. M3U/Stalker items
still go per-playlist because they update a JSON column on the
playlist row, not the recently_viewed table — but they now run in
parallel with the Xtream batch via a single Promise.all.
- Single call site updated: unified-collection-page "Clear all of type"
confirmation handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 4bfb62f988b7
The inner loop in reorderGlobalFavorites() rebuilt the same
update().set().where() AST per row — up to thousands of times for large
favorite lists. Hoist a prepared statement using sql.placeholder() so
Drizzle generates the SQL string once and SQLite caches the parsed plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3d6901072049
SQLite's recommended approach for keeping query plans current: cheap when
nothing needs analyzing, runs incremental ANALYZE on tables/indexes that
have grown significantly since the last run. Wrapped in try/catch since
optimize is advisory and must never block connection close.
Applied to both the main connection and the EPG worker connection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3d6901072049
Each chunk in a bulk insert/update/delete loop was running as its own
implicit transaction, triggering one WAL commit (and one fsync, even with
synchronous=NORMAL) per chunk-internal statement. Wrap each chunk in a
single Drizzle transaction so the chunk commits as a unit.
Per-chunk transactions (not whole-loop) preserves:
- Cancellation between chunks via checkpointOperation()
- Async progress reporting via reportOperationProgress()
- Bounded write-lock duration (no minutes-long single transaction)
Sites updated:
- content.operations.ts: Xtream content bulk insert + clearXtreamImportCache deletes
- playlist.operations.ts: upsertAppPlaylists loop + cascade delete chunks
- favorites.operations.ts: reorderGlobalFavorites nested update loop
- xtream.operations.ts: cascade content/category deletes + favorites/recently-viewed restore
Highest impact: Xtream content imports (10k-100k+ rows) and M3U playlist
upserts. EPG worker already used transactions correctly — unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3d6901072049
Pair the existing journal_mode=WAL with the companion pragmas it needs to
actually pay off, and reduce read latency on hot query paths.
- synchronous=NORMAL: ~2-10x faster writes when paired with WAL. Safe — only
risks losing the last committed txn on power loss; DB stays consistent.
- cache_size=-64000: 64MB page cache (default is 2MB).
- temp_store=MEMORY: keep sort/group temp tables in RAM.
- mmap_size=268435456: 256MB memory-mapped I/O for reads.
Applied to both connection sites: the main read-write/read-only connection
in libs/shared/database and the EPG worker connection in electron-backend.
The per-connection pragmas (cache_size, temp_store, mmap_size) apply to the
read-only agent-backend connection too.
Inspired by matracey/iptvnator@4ad8f88; ported manually since the file has
diverged significantly and the worker connection didn't exist in that fork.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3d6901072049
- Introduced tooling for building and staging the macOS `libmpv` runtime for IPTVnator's embedded MPV player.
- Added `build-macos-runtime.mjs` for building an LGPL-compatible runtime from source.
- Created `stage-macos-runtime.mjs` for staging the built runtime artifacts.
- Implemented validation for the packaged embedded MPV runtime in `electron-after-pack.cjs` and `embedded-mpv-macos.cjs`.
- Updated packaging scripts to ensure the embedded MPV runtime is correctly integrated and validated during the build process.
- Added README files to document the expected layout and usage for the embedded MPV runtime artifacts.
Entire-Checkpoint: c6e522b4276c
Stop treating content-encoding as a manual gunzip signal, because fetch already returns decoded bodies for transfer-compressed XMLTV responses.
Keep redirect and payload-based gzip detection in place, and add regression coverage for redirected .gz feeds, gzip MIME types, content-disposition filenames, and plain content-encoding responses.
Entire-Checkpoint: c6e522b4276c