Four more from review, three of them defects in last round's fixes.
The restore normalizer materialized `sourcePins: []` for archives that never
had the field, so "absent means no opinion" became "this archive says there
are no pins" and a merge cleared the user's. Absent now stays absent. My test
for that behaviour had passed for the wrong reason — it stubbed an empty pin
list, so the clear was skipped whether or not the guard worked.
`startGeneration` was claimed only by the switch path, so a plain Play, Resume
or Restart could be overtaken by a switch still awaiting its close. Every
start claims it now.
Raw and normalized tokens were paired by position, which breaks when
normalization drops a whole word: "FR: Ça" normalizes to "ca" and got handed
the raw token "FR:", sending it down the ASCII branch it cannot match from.
They are paired by normalized form instead.
And the ambiguous yearless alias (`title:dune:`) is no longer written or
retired beside a precise key — it may hold another remake's pre-enrichment
pin. It stays available when it is the only key there is, since refusing to
pin at all would be worse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three follow-ups, two of them to last round's own fixes.
The external-session close was defeated in exactly the case it was written
for: `switchToSource` marks the DESTINATION active before handing playback
over, so by the time the service ran, the process still playing no longer
looked like ours and was left running beside its replacement. The service now
remembers the ids it launched with, independently of what is active.
The ASCII/Unicode branch was decided from the NORMALIZED token, which folds
diacritics — "Ça" arrived as "ca", looked like plain ASCII, and took the GLOB
path while the stored title still read "Ça". Decided from the raw token now.
Backup restore upserted archived pins but never removed the playlist's
existing ones, so a present-but-empty collection left stale preferences alive
— unlike the playback positions cleared beside it. An absent collection (an
older archive) still means "no opinion" and is left alone.
Four files crossed the size cap on the way; the split ones now share
`title-sources.spec-data.ts` and `playlist-backup.xtream-fixtures.ts`, and the
external-session ownership moved to its own module.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five from review.
Greptile's P1: a short non-ASCII title was undiscoverable. SQLite's `LOWER()`
and GLOB classes are ASCII-only, so "он" never matched a stored "Он" and the
source simply never appeared. ASCII tokens keep the word-boundary GLOB; a
non-ASCII token falls back to a substring test against both the folded and the
as-typed form, which the normalized confirmation afterwards makes safe.
A probe now retries the ranged GET for 400 and 403, not just 405/501 — those
are what a WAF returns for an unexpected HEAD on a URL it serves happily over
GET, and calling that source dead also ranked it below worse ones.
Three races, all the same shape as ones fixed earlier in this branch:
- a pinned play awaiting its resume lookup did not notice a source picked
across it, and finished last, replacing the user's choice;
- two overlapping switches both saw the same external session, both awaited
its close, and both launched — two detached players again;
- the primary button showed the ROUTE copy's Resume while the pinned copy's
row was still loading, so a click started somewhere else entirely.
Also puts the races spec on the shared host harness, which is what keeps it
inside the file-size rule now that it carries two more cases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Split across two calls, a half-failure had no honest outcome. Reporting
success left a surviving alias to win the next lookup and start the source the
user had just replaced; reporting failure — which the previous round changed
it to — left the canonical row durable while the UI showed a pin that was no
longer the stored one. Review was right both times, which is the tell that the
two-step shape was the problem.
`setVodSourcePin` now takes the keys to retire and does both inside one
`db.transaction()`, with the synchronous `.run()` form the better-sqlite3
driver requires there (issue #1137's lesson). `retireKeys` rides through the
worker op, the IPC contract, the preload bridge and the service, so there is
one call and one outcome.
Also corrects the architecture doc: the scan path is reached whenever no token
clears the trigram minimum, not only when the whole title is one or two
characters — the claim the previous commit's code change had already falsified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(packaging): register IPTVnator as the .m3u/.m3u8 handler
Every runtime path for an OS-supplied playlist existed, but no packaging
metadata claimed the file types — so the OS never offered IPTVnator as a
handler and `open-file` could not fire from Finder.
`fileAssociations` declares one entry per extension. Electron Builder derives
all three platform registrations from it: macOS `CFBundleDocumentTypes` (the
prerequisite for `open-file`), the NSIS registry entries, and, on Linux, the
desktop entry's `MimeType` plus `/usr/share/mime/packages/iptvnator.xml` for
deb/rpm/pacman. Neither platform needs a dedicated icon — both fall back to the
app icon.
Declaring `MimeType` under `linux.desktop.entry` would not have worked:
Electron Builder assigns the association-derived value *after* spreading that
object, so an explicit key there is silently overwritten. The per-association
`mimeType` fields produce the same entry through the supported path.
Registering the types also exposes a gap in the delivery side. The generated
Linux `Exec` ends in `%U`, so file managers hand over a percent-encoded
`file://` URI rather than a path, which `createPlaylistOpenRequest` would have
resolved into a bogus relative path. It now decodes a `file://` candidate
before the extension check. Suppressing the `%U` instead would mean putting an
exec code in `linux.executableArgs`, which also passes it to the app as a real
argument.
Verified on macOS against a signed packaged bundle: Launch Services lists the
app as a `public.m3u-playlist` handler, and an LS-initiated open imports the
playlist both on a cold launch and against the already-running process.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(playlist): open every playlist of a multi-file selection
`%U` is the plural exec code, so selecting several playlists in a Linux file
manager is one launch carrying one argument per file. Both argv paths called
`extractPlaylistOpenRequestFromArgv`, which returned at the first match, so
everything after the first playlist was silently discarded — a gap this PR
itself opened by making the desktop entry reachable in the first place.
The extractor is now plural and returns every match in argument order, and the
queue gained `enqueueAll` so a selection is pushed under a single flush: a
delivery that fails partway leaves the untouched remainder queued in arrival
order rather than interleaved.
Covered by unit tests over a mixed argv (percent-encoded `file://` URI, a
non-playlist argument, a second URI) and by a new Electron E2E that launches
with two playlist arguments and asserts both are imported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Three from review.
`info.video`/`info.audio` are declared — and sent by the mock server and many
panels — as string arrays, but the resolver only read the ffprobe object shape.
Every array response therefore lost the provider's codec, so those source rows
showed no codec fact and the "dub may differ" warning could never fire.
`readStreamInfo` now accepts both, and states nothing when the provider stated
nothing.
The FTS-empty fallback scan matched only the FIRST token, which is fine for a
one-word short title but not for "I Am": every catalog row containing the word
"i" came back for TypeScript to throw away — a full scan of a large catalog on
the single database worker, just to open a detail page. Every token must now
appear.
`writePin` reported success when the canonical write landed but retiring the
old alias failed. Lookups read aliases before the canonical key, so reopening
the movie before enrichment would start the source the user just replaced,
with the icon promising otherwise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening an .m3u/.m3u8 file from the command line or a file association did
nothing. The renderer parsed `process.argv` and sent an `OPEN_FILE` IPC event
that had no `ipcMain` handler and no preload channel, so `sendIpcEvent` logged
it as an unknown type and dropped it.
The path now belongs to the main process, which is where the OS actually
delivers it:
- argv is parsed on first launch (skipping the executable and Chromium
switches) and normalized to an absolute path;
- macOS gets an `open-file` listener registered before `whenReady`, since
Launch Services never puts the path in argv;
- the single-instance guard forwards a second launch's argv and working
directory instead of discarding them, so opening a playlist against a
running app works too.
Requests are queued in the main process until the renderer subscribes to the
`OPEN_FILE` push and drains the queue, which closes the startup race. The
import itself reuses the existing file path, so persistence, playlist-scoped
EPG and the navigation to the new playlist behave exactly like a dialog
import; a failed open now surfaces a snackbar instead of silence.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The new pins table was invisible to backup: exporting a playlist and
re-importing it on a new machine silently dropped every "main source" choice,
with nothing in the archive to say the choice had ever been made.
Pins now ride along under the playlist they point AT — carrying them anywhere
else would restore a preference for a portal the archive never contained.
`matchKey` names the film rather than the portal, so it survives untouched and
only the playlist id is remapped to the imported copy.
`sourcePins` is the one optional collection in the Xtream user state: archives
written before multi-source existed simply do not have it, so its absence is
age rather than damage. Only a wrong type is rejected, and pins without a
usable match key or content id are dropped, since writing one would occupy the
unique key of a film it does not describe.
Adds `DB_LIST_VOD_SOURCE_PINS` through the usual six seams (operation, worker
case, event, preload, bridge contract, service).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Master had moved ten commits ahead. Two conflicts, both resolved without
losing either side: the `XTREAM_PROBE_URL` handler this PR extracted into
`events/stream-probe.ts` stays extracted (master added performance capture
nearby but never touched that handler), and the mock-server scenario table
takes the union of master's `performance` row and this branch's `multisrc`
pair.
Two findings fixed alongside it.
Pinning the copy the route is already on makes discovery return that very
row, so prepending the current source listed one stream twice — a phantom
copy in the grouping and a chip that counted it.
And handing the "playing" badge back to the route row left an in-flight
switch valid, so a slow resolution could arrive afterwards and replace the
playback the user had just started with Play, Resume or Restart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three findings from the latest review pass.
`normalizeTitleKeys` strips bracketed segments as tag noise, so "Dune (1984)"
normalizes to exactly "dune" — an EXACT match for the 2021 film, ranked above
every fuzzy one, with the year never consulted because that tier skipped the
gate. Auto-failover could switch the user to the other film entirely. The
year is now read out of brackets too, and a stated disagreement rejects the
row on either tier.
Playback positions are keyed by (playlist, stream), so watching through a
pinned alternative stores progress under ITS ids while the page loads the
route copy's row. Starting the pin therefore resumed from a position
belonging to a different copy — usually zero. It now loads its own.
And a pin can point at another copy of the film inside the playlist being
viewed, which discovery excludes wholesale: the pinned row was absent from
the list, so nothing showed as pinned and Play ignored the preference. The
pin is now read before discovery, which keeps that one row.
Moves the pin-shaped decisions into the pin module, where the persistence
helpers already live.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five findings from the latest review pass.
Writing a pin to every alias — last round's fix for stale aliases — was
wrong in the other direction: `title:{base}:` is shared by every remake, so
a known-year decision stored there answers for a different film. Pin Dune
(2021), open Dune (1984) before its year arrives, and it would start the
2021 source. A write now clears every alias and stores only the canonical
key, which retires the stale ones without making any of them ambiguous.
The probe checked a bare URL while playback sends the playlist's User-Agent,
Referer and Origin. A panel that requires them answers 401/403, so a stream
that plays perfectly was reported dead and penalised in failover ranking.
The switch toast interpolated the raw playlist name. Users routinely name a
playlist after the URL they pasted, so that line could put credentials over
the video; the notice now carries the same safe label the rows use.
External players have no timeupdate, so their polled position IS the live
one. Feeding it through the seed — which stops at the first value — froze
the resume point where playback started, and a switch an hour in rewound to
the beginning.
And auto-failover concluded "nowhere to go" when a stream failed before
discovery answered, stranding the user on the error screen.
Moves `switchTo` into the session module, which is where the rest of the
switch mechanics already live, and splits the route spec along the same
rendering/behaviour seam the other suites use.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four findings from the latest review pass.
A pin lookup accepts several aliases of the same movie, but a write only
touched the most-trusted one — so after enrichment the title alias still
pointed at whatever was pinned before, and a reopen that read it (because
TMDB had not landed yet, or its request failed) started the source the user
had just replaced. Writes now go to every alias.
That alias set was also missing one. Enrichment supplies the year as well as
the id, so a pin set before either existed is stored yearless; the candidate
list skipped that form entirely and orphaned the row.
Discovery could lose whole playlists: one playlist listing a film in dozens
of categories produces identically ranked rows that fill the window before
another playlist is read. The collapse now happens in SQL, before the limit,
rather than in TypeScript afterwards where the missing rows are already gone.
And an abandoned source pick finishing late cleared the spinner from the row
the user was actually waiting on.
Removes `isExhausted()` from the host service — no caller outside its own
tests, where the assertion above it already proved the same thing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Settings live in the renderer's IndexedDB, and two failure modes made them look
saved while nothing reached disk.
A second app instance sharing the same userData directory cannot take the
Chromium storage lock, so its renderer reads defaults and every write is
dropped. The app now holds a single-instance lock and focuses the running window
instead of starting a rival copy. The lock is requested after the userData
override so E2E runs with their own data dir keep independent locks, and after
Squirrel event handling. IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1 opts out for local
CDP debugging.
updateSettings() patches in-memory state before persisting and the submit path
had no rejection handler, so a failed write produced an unhandled rejection and
no user-visible feedback. SettingsStore now records which half of the round trip
failed, and the settings page surfaces it through a dismissible error snackbar;
the dialog stays open on failure so the save can be retried.
Two follow-ups from review, both wider than the report:
- a second launch now re-creates the main window when the lock owner has none
left, so closing the last window on macOS no longer leaves a second launch
quitting silently with nothing on screen
- App.onMainWindowCreated() re-runs window-owned bindings for every rebuilt
window, so the downloads broadcaster stops holding a destroyed window. This
also fixes the same bug on the pre-existing dock `activate` path.
Closes#1156Closes#102
* fix(electron-backend): make test suite and lint host-agnostic across Windows/Linux checkouts
Windows checkouts (core.autocrlf=true) had 13 pre-existing jest failures
and 5 Windows-only lint errors in electron-backend while Linux CI was
green:
- embedded-mpv-native-source.spec: normalize CRLF after readFileSync so
multi-line source assertions match on autocrlf checkouts
- worker-runtime-paths.spec: build expected candidate paths with
path.join instead of hardcoded POSIX strings
- external-player-launch-context: join darwin-only paths (.app bundle
executables, Homebrew Caskroom) with path.posix.join so simulated
darwin platforms resolve correctly on win32 hosts (no-op on macOS)
- app.spec: build packaged-navigation fixtures with path.resolve +
pathToFileURL; file:///tmp/... is not a valid win32 file URL
- lint target: quote the eslint glob. The unquoted ** was expanded by
the POSIX shell on Linux (shallow match), so CI linted only a subset
of files while Windows passed the literal pattern to ESLint and
linted the full tree - the hosts checked different file sets
- fix the 5 errors full-tree linting surfaces: prefer-const in
epg-worker.service and database.worker-connection, no-unsafe-finally
in external-player-session-registry and embedded-mpv-native.service
(rewritten as catch-swallow with identical semantics, pinned by new
regression tests), intentional no-control-regex in the recording
filename sanitizer; drop stale unused disable directives
- document the quoted-glob convention in CLAUDE.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: mirror the quoted-lint-glob convention into AGENTS.md
AGENTS.md already mirrors the neighbouring max-lines/baseline paragraph from
CLAUDE.md, and it requires coding conventions to stay in sync between the two
files. The quoted-glob rule landed only in CLAUDE.md, so agents bootstrapping
from AGENTS.md could reintroduce a host-dependent lint target.
Also corrects the wording in both copies: the shallow expansion happens on
macOS as well as Linux — /bin/sh has no globstar on either — and the target
still exits 0 with a broken glob, which is why this went unnoticed. Adds the
file-count check that catches it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The mapping handlers documented a fail-soft contract but only honored half
of it. Four of them returned the database operation's promise from inside
the `try` block without awaiting it, so the rejection escaped the `catch`:
the try block exits before the promise settles. A `getDatabase()` failure
was caught, but a SQLite error in the operation rejected the
`ipcMain.handle` promise, and the renderer call threw instead of receiving
`null` / `{success:false}` / `[]`.
Adding `await` in handleGetEpgMapping, handleSetEpgMapping,
handleDeleteEpgMapping and handleSearchEpgChannels closes the gap.
resolveChannelIds and handleGetEpgMappingsBatch already awaited correctly
and are unchanged.
This is pre-existing — the same shape predates the epg.events.ts split in
636545cb, which preserved semantics faithfully and inherited the bug.
Adds epg-mapping.service.spec.ts, the first coverage these handlers have
had: table-driven over all six functions against both failure modes, plus
the guard clauses and queryByResolvedChannelIds remapping. Verified to fail
on the old behavior — reverting the four awaits fails exactly the four
operation-rejection cases.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Two findings from the review of the previous round.
A pin write is an IPC round-trip, and the user can navigate during it. The
continuation then applied one film's answer to another film's controller —
and because unpinning returns "nothing pinned", it would clear the pin the
new movie had just loaded and its Play action would quietly stop starting
from the preferred source. It now commits only while the same film is still
on screen, like every other async path here.
The short-title scan drops its row limit. FTS keeps its window because it
ranks by relevance, so what it keeps is what matters; a scan cannot rank, so
a window there silently decides which valid sources the user is allowed to
see. It also bought nothing: the GLOB cannot use an index, so SQLite reads
every row either way and the limit only truncated the answer. What bounds
the scan is its predicate — reaching it means the whole title is one or two
characters.
The switch-notice type moves to the module that builds it, which also
removes a circular type import between the two.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(performance): deflake the real-timer event-loop delay spec
The real-timer capture spec failed under full-suite parallelism because
`monitorEventLoopDelay()` records nothing on its first internal timer
tick - that tick only seeds the previous timestamp, so the first delay
sample lands on the second tick. Condition-based arming therefore needs
two event-loop turns inside its fixed 50ms wall-clock budget, and a
machine running 10 Jest workers stretches a single turn past 20ms. The
capture then degraded to a documented `event-loop-delay-arm-timeout`,
which is the intended graceful path, while the spec asserted the happy
path of that race and turned an environmental outcome into a red build.
Retry the real-runtime capture within a 5s budget instead. The real
`node:perf_hooks` runtime and the real 20ms block are kept, since the
fake harness returns a hard-coded histogram max and never measures
anything. Every attempt still asserts a contract: instrumentation never
breaks the wrapped work, and a null delay must carry a documented
arm/flush timeout rather than being silently null. Budget exhaustion
warns instead of failing, so load can no longer produce a false failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(performance): assert the real delay measurement unconditionally
Codex flagged that budget exhaustion still passed the test, so a
regression that made arming or flushing time out on every attempt would
have been reported as a warning rather than a failure - removing the only
assertion backed by Node's real histogram and a genuine event-loop block.
Drop the tolerant retry loop. The arming deadline is read through the
injectable `readMonotonicMs()`, so scaling only that clock leaves the
wait bounded by its other limit, the 50-poll ceiling, which is ~25x the
two event-loop turns arming actually needs. Everything else stays
production code: the real `monitorEventLoopDelay()` histogram, real
`setTimeout()` polling, and real epoch/CPU/ELU boundaries. The scaled
clock reaches nothing but the wait budgets, since its only other consumer
records phase events and this spec records none.
The test now always asserts a real measurement and hard-fails otherwise.
Verified by mutation: forcing arming to never arm fails it, and dropping
the deliberate block fails it at maxMs 3.8ms against the 10ms floor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Six review findings, all in how multi-source decides what to show and what
it is playing.
Discovery: the current playlist is now excluded in SQL rather than after the
fact, so its own duplicate rows can no longer spend the whole row budget
before a single alternative is read. The short-title scan matches the token
as a word instead of a substring and orders by title length in a wider
window, so "Titanic" and "The Italian Job" cannot push the real "It" out of
it.
Session: metadata enrichment re-runs discovery for the film already on
screen. That is a refresh, not a new session — a second identity key
(playlistId:contentId) now separates the two, so the source the user
switched to keeps playing and stays named, the tried set stays burned, the
position survives and a switch in flight still commits.
Resume: the multi-source controller no longer records the engine's pre-seek
timeupdate at ~0. The playback service's one-shot latch now reports whether
the position can be believed, and until it can, the requested start time
stands in — so a switch during the initial seek does not restart the film.
UI: the in-player sources picker gets the same auto-failover setting and
match kind as the detail page's, instead of always rendering the default and
dropping the toggle. The caption counts distinct playlists, not stream
variants, since the popover groups a portal's copies under that portal.
Session mechanics and the pin toggle move into their own modules to keep the
host service inside the line budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to #1278, which split four of the files the electron-backend lint
target had been silently skipping. Four were left over; this splits them, so
no file in the project sits above the 400-line cap outside the baseline.
None are added to tools/eslint/max-lines-baseline.mjs — the baseline only
shrinks. Shared setup moves to *.test-helpers.ts, the suffix
tsconfig.app.json already excludes, so the production build never sees jest
globals.
- remote-control.events.spec.ts 588 -> 188, plus remote-control-http.spec.ts.
The mock registry moves to remote-control.test-helpers.ts; each spec keeps
its own jest.mock() factories, which resolve the mock-prefixed exports.
- downloads.events.spec.ts 530 -> 182, plus downloads-actions.spec.ts. The
jest.doMock setup is not hoisted, so the whole harness moves to
downloads.test-helpers.ts behind setupDownloadsEventsHarness().
- http-server.spec.ts 448 -> 377, plus resolve-static-file-path.spec.ts. The
resolveStaticFilePath cases were already self-contained.
- worker-performance-capture.spec.ts 408 -> 149, plus
worker-performance-capture.resilience.spec.ts, matching the .concurrency
and .histogram siblings.
Test bodies are unchanged; the helpers keep the same identifiers in scope so
the splits are a move, not a rewrite.
Verified with the glob quoted locally (that quoting is #1176's, not part of
this change): 245 files, 0 max-lines errors, and the only remaining lint
errors are the five #1176 fixes. Both tsconfig.app.json and
tsconfig.spec.json typecheck clean.
Note: worker-performance-capture.concurrency.spec.ts is flaky on master
independently of this change — it asserts a real 20ms event-loop block and
failed 4/4 clean-master runs here.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Addresses three defects Greptile found in the multi-source review.
**Concurrent switches committed out of order.** Selecting a second source
before the first resolution returned let the slower request overwrite the
newer selection and repoint Undo at itself. `switchTo` now takes a sequence
number and drops its result if a newer switch already committed.
**Stale switches crossed movie sessions.** Navigating to another film while a
resolution was in flight let the continuation activate the old film's source
inside the new controller — and restart it from that session's zero resume
position. The controller is now snapshotted per operation and the movie
session is revalidated after every await. `check()` had the same hazard across
its two awaits and is guarded the same way.
**Short titles skipped discovery entirely.** The trigram tokenizer cannot index
tokens under three characters, so "Up", "It" or "Us" produced an empty MATCH
expression and the query was discarded before SQLite was consulted — the chip
could never appear for those films. Discovery now falls back to a bounded scan
when FTS structurally cannot serve the title; the existing two-tier normalized
confirmation still rejects loose hits like "Upgrade".
Each fix carries a regression test; all three were mutation-checked by removing
the guard and confirming exactly those tests fail. The previous test asserting
that short titles return nothing encoded the bug and has been replaced.
The host spec passed 400 lines, so its fixtures moved to a shared module and
the race suite into its own file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four EPG mapping handlers wrap their DB call in try/catch but return the
promise instead of awaiting it. An async function *adopts* a returned promise
rather than awaiting it, so the catch block only ever fired when getDatabase()
itself threw — a rejection from the underlying query escaped to the IPC caller
instead of returning the intended null / {success:false} / [] fallback.
Add the missing await to handleGetEpgMapping, handleSetEpgMapping,
handleDeleteEpgMapping and handleSearchEpgChannels, matching
handleGetEpgMappingsBatch and resolveChannelIds in the same module, which
already awaited and so already failed soft for both cases. This restores the
contract stated in the module's own doc comment: a mapping lookup must never
take down an EPG request.
The behavior predates the max-lines split in #1278, which carried it over
verbatim from epg.events.ts.
The new spec drives the real IPC handlers captured from ipcMain.handle, so it
asserts the contract that matters: the caller gets a fallback, not a rejected
promise. Reverting the four awaits fails exactly those four handlers and
leaves the already-correct batch handler green.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A movie that exists in several imported Xtream playlists now shows a
"Sources N" chip on its detail page and in the player. Switching playlist
mid-film keeps the timecode, a preferred source can be pinned per movie, and
a failed stream offers the alternatives instead of a dead end.
The governing rule is that a guess is never presented as a fact. Every
metadata value carries where it came from — `api` (the provider said so),
`parsed` (inferred from the title) or `probe` (we contacted the stream).
Facts render as plain tags, guesses are prefixed `~` in a warning colour, and
an unknown value renders no tag at all plus a "check" affordance. Ranking and
failover read through `factualOnly()`, so a filename claiming 4K is
structurally unable to outrank a source that was actually reached. A probe
that could not complete reports "unknown", never "unavailable".
Scope is deliberately narrow: Xtream to Xtream, movies only, Electron only.
Stalker never reaches the `content` table and M3U is a JSON blob whose search
forces live content; both are additive later, since the candidate type
already carries all three portal kinds. In the PWA every entry point is gated
off and the chip renders nothing.
Auto-failover is opt-in and off by default. Each source is tried at most once
per session, so it terminates structurally, and the switch is never silent —
the toast names the new playlist, offers an undo, and warns that the dub may
differ only when both sides state an audio track as fact.
Notable details:
- Playlist names are routinely the pasted URL, credentials included. They are
never rendered raw; a short host-only label is derived instead.
- Quality is derived from pixel width, not height: a 2.39:1 1080p master is
1920x800, and bucketing that by height would publish "720p" as a fact.
- Switching is a single `inlinePlayback.set()` so the player and engine
survive and re-seek; the carried position is read before the 15s
persistence throttle so it does not rewind.
- Sources from one playlist collapse into a group, since the same film often
appears there several times under different stream ids.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Split epg.events.ts (514), the embedded MPV frame-copy adapter (428) and two of its specs (547, 539) below the 400-line hard limit, and teach the baseline generator to skip files that already carry a justified file-wide eslint-disable max-lines.
The generated baseline list is unchanged: 128 entries before and after. No behavior change.
Adds contract-focused regression coverage for the Electron HTTP server,
remote-control events, settings events, and managed download paths, and makes
Tier A coverage fail closed when instrumentation fails or a runtime-owning
production file disappears from a project or from the merged Istanbul report.
The old `coverage:ci` exited 0 despite a `Failed to collect coverage`
diagnostic: libs/m3u-state/src/lib/effects.ts was simply absent from the merged
map. All 30 Tier A reports are now required, the merged map covers 710 files,
and effects.ts is reported as 0/159 instead of silently disappearing.
Also fixes remote static-file path containment for encoded, malformed, NUL,
POSIX and Win32-style traversal inputs, with behavior-preserving testability
seams.
Statements 69.27% -> 69.54%; http-server.ts 0% -> 90.21%,
remote-control.events.ts 0% -> 96.55%, settings.events.ts 59.25% -> 96.29%.
Rebuilt from #1251 so the group could merge, on top of the transitive-CVE
overrides from #1258. Supersedes #1230 and #1251.
Carries axios 1.16.0 -> 1.18.1, closing seven runtime-scope advisories
including the proxy-credential leak on redirects, and sharp 0.34.5 -> 0.35.3
for the libvips CVEs.
`esModuleInterop` moves to tsconfig.base.json. artplayer 5.4.0 switched from a
Parcel build exposing `module.exports.default` to UMD assigning
`module.exports` directly; the flag was only set in apps/web, so every lib
compiled `import Artplayer from 'artplayer'` to `.default` and got undefined.
Production was never affected — esbuild resolves the ESM entry.
Two packages are deliberately held back, each for its own PR:
- epg-parser ^0.5.0 — grouped as a minor, but 0.x minors are breaking and this
one reshapes the parse output (`channel.name` -> `displayName`, icons/urls
become objects, `credits` becomes role-keyed, dates switch to ISO). Its only
consumer is the uncovered web-backend `/parse-xml` endpoint.
- electron-builder ^26.15.3 — rewrote the snap target, and the resulting snap
cannot start (`command.sh` execs a `desktop-init.sh` that never lands at the
snap root under our core22 strict config). Its two required fixes go with it:
the `engines` node floor from @electron/rebuild 4, and resolving upstream
node-gyp instead of the dropped `@electron/node-gyp` fork.
The custom minimize/maximize/close controls stayed hidden forever after
leaving HTML-element (video player) fullscreen on Windows: window state was
polled at event time, and isFullScreen() can still report the pre-transition
value while 'leave-full-screen' fires, leaving a stale push with no later
event to correct it. The same polling on the companion flag cleared
isMaximized during fullscreen transitions and stuck the maximize/restore
glyph on the wrong icon.
attachWindowStateEvents now seeds the state once at window creation and each
event patches only the flag it names, sending a copy per push. The
enter/leave-html-full-screen variants are wired too.
Regression coverage: app-window-state.spec.ts (9 cases, 6 of which fail
against the old implementation) and an Electron E2E case that toggles HTML
element fullscreen and asserts the controls come back.
* feat(tmdb): metadata cache panel with a clear button in settings
Adds "Metadata cache — N entries · X MB" with a Clear button to
Settings > Metadata (TMDB), next to the API key it belongs to.
Three things it is good for: dropping stale or wrong metadata so the next
open refetches it, seeing what the cache actually costs on disk, and
reclaiming rows that a lookup-key version bump has orphaned — a bump makes
rows unreachable, not deleted, so nothing else would ever collect them.
Sizing the cache is a full table scan (LENGTH() on TEXT counts characters,
so the SUM casts to BLOB to get bytes), which is why stats load lazily and
only once the TMDB section is the active one rather than on every settings
open. Clearing is always safe: enrichment refetches on demand, so the only
cost is the next few requests.
Works in both environments — the PWA has no bridge, so the service reports
and clears its session-scoped in-memory map instead.
i18n: 4 keys across all 19 locales via the tools/i18n workflow;
placeholder integrity verified. Contract fixtures updated for both the
preload bridge and the DB-worker payload shapes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): make cache clearing durable and stop reporting failures as empty
Four review findings, all real:
- A metadata write already in flight when the user cleared would land
afterwards and silently restore what they removed. Writes now carry the
generation they started in; a write that outlives a clear is dropped
(PWA) or undone (Electron).
- The PWA byte count used String.length, i.e. UTF-16 code units, so
localized payloads under-reported and disagreed with the SQLite BLOB
byte count. TextEncoder now measures actual bytes.
- A failed stats read returned a valid zero-entry result, so the panel
claimed an empty cache and disabled Clear while rows were still there.
getStats/clear now return null on failure and the panel says so instead
of inventing state.
- No behavioural coverage existed for either side.
Tests: SQL ops (entry/byte reporting, empty table, missing row, delete
count) and the service (encoded bytes, clear count, and a write racing a
clear).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): make the cache clear precise and version skew visible
Review follow-ups on the cache panel:
- A write that was in flight when the user cleared used to trigger a
second full-table clear once it landed, which also deleted anything
written in between. clear() now waits for the writes issued before it
and lets the single clear take them; later writes survive.
- An Electron shell without the maintenance ops fell through to the
renderer map, which is always empty there — it reported an empty cache
and disabled the Clear button while SQLite was full. Both operations
now report unsupported instead.
- Component coverage for the panel (deferred scan, clear + re-read,
failed clear, failed read) and Electron-path service coverage.
- The canonical IPC and settings sections of the enrichment doc, plus
the matching CLAUDE.md lines, now list the maintenance ops.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): drop Promise.allSettled from the cache clear
The web target compiles against lib es2018, so allSettled broke the
Windows frontend build (TS2550). The pending writes swallow their own
errors, so a plain Promise.all over neutralized promises does the job.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): keep a synchronous bridge throw inside the cache write
Moving the write into a tracked promise dropped the try/catch that used
to cover the call itself, so a bridge that threw synchronously would
escape set(). Wrap it in an async IIFE, which turns that back into a
rejection the same handler swallows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): retry the cache size read when the section is reopened
The effect skipped the read once cacheError was set, so one transient
IPC failure left the panel showing "could not read the cache" for the
life of the settings page — and the only enabled control that could
shift it was the destructive Clear button. Gate on the stats signal
alone: reopening the section retries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): queue writes that start while the cache is being cleared
Awaiting the in-flight writes closed one side of the race and left the
other open: a set() that started during that wait dispatched its IPC
immediately, was absent from the snapshot, and could reach SQLite just
before the delete — so a row written after the user clicked Clear was
removed anyway.
clear() now holds its own promise for the whole operation and set() waits
on it, which puts such a write on the far side of the delete. Rows are
stamped when they are dispatched rather than when set() was called, since
a write may have waited. Covered by a test that fails without the guard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(tmdb): add the release note for the cache panel
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(tmdb): cover the cache panel with an Electron E2E
The panel drives IPC and SQLite, and nothing exercised that path end to
end. The new test seeds a row through the preload bridge — enrichment
itself needs a TMDB key that CI does not have — then opens the section,
asserts the reported size, clears, and reads the database back to confirm
the row is gone rather than merely hidden.
Verified both ways: dropping the DELETE from clearTmdbMetadata fails it.
Settings nav buttons gained a data-test-id so the section can be opened
without matching translated labels.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The startup auto-refresh always opened the
`HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SUCCESS` snackbar, even when the backend
had dropped playlists it could not refresh. Isolating per-playlist failures
(#1233) means the result set is lossy by design, so an unreachable source that
now fails within `PLAYLIST_FETCH_TIMEOUT_MS` produces a false success toast.
`autoUpdatePlaylists()` now returns `AutoUpdatePlaylistsResult` — the refreshed
playlists plus one outcome per requested playlist (`updated` / `failed` /
`skipped`), in request order — on top of the existing bounded-concurrency
refresh. The renderer derives the message from those outcomes:
- all updated -> `AUTO_REFRESH_UPDATE_SUCCESS` (unchanged)
- some failed -> `AUTO_REFRESH_UPDATE_PARTIAL` (error styling, dismissable)
- some failed and some skipped -> `AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED`
- none updated -> `AUTO_REFRESH_UPDATE_FAILED` (error styling, dismissable)
- only sourceless playlists left over -> `AUTO_REFRESH_UPDATE_SKIPPED`
Playlists with neither a URL nor a file path are reported as skipped rather
than failed, since there is no source to refresh them from. The mixed
failed+skipped message exists because the plain partial text names only
updated/total/failed, which would leave the skipped playlists as an
unexplained remainder. Titles of unresolved playlists are logged for
diagnosability.
Tests: five new `electron.service` cases (one per message branch), outcome
assertions across the existing `playlist-auto-update` and `playlist.events`
specs, and an Electron E2E that restarts the app against a killed playlist
server — verified to fail against the old unconditional toast.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Playlist downloads in the main process ran without an axios timeout, so a
host that accepted the connection and then went silent kept the request
pending forever. The startup auto-update refreshed playlists sequentially,
so that one unresponsive source also withheld every playlist that did
refresh, and the URL import dialog could spin with no way out.
- Add PLAYLIST_FETCH_TIMEOUT_MS (30s) to the main-process fetch and reuse it
in the refresh worker instead of its duplicated literal. Redirects are
followed one hop at a time, so each hop is bounded separately.
- Move auto-update into playlist-auto-update.ts and refresh at most
AUTO_UPDATE_CONCURRENCY (3) playlists at once, isolating each failure while
preserving the requested order. An unbounded fan-out would download and
parse arbitrarily many large M3U files in the main process at once.
- Redact refresh log URLs, which routinely carry Xtream username/password
query parameters.
The existing auto-update spec handed out fixtures by call order, which no
longer holds once refreshes overlap; it now keys them by source type.
Fixes#931
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Category rows crossed the DB-worker IPC boundary with Drizzle's camelCase
property names while the renderer contracts declare snake_case, so backup
export dropped hidden-category IDs and restore degraded to a type-only
match that hid every category. Project category ops to the declared wire
shape, normalize restore state from untrusted sources (dropping entries
without a numeric xtreamId), reject entries with missing user-state
collections, and add full export→import round-trip coverage (unit
manifest-equality + Electron e2e) plus regression tests.
Closes#1017
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a paused state to the Electron download manager with a full partial-file lifecycle:
- Pause keeps the .part and byte progress; cancel discards them; every lifecycle stage (queued, active, mid-transfer) is pausable.
- Resume continues via HTTP Range with If-Range entity validation (strong ETag / Last-Modified persisted in the new resume_validator column, idempotent migration incl. legacy-table rebuild). Non-206 answers restart from zero over the same .part; the 206 Content-Range offset is verified; responses that end before the advertised size are retained for a Range retry instead of being committed as completed.
- Crash recovery converts interrupted transfers to paused, keeps queued-with-partial rows resumable, and commits finalizations that crashed before the DB update.
- Destination collisions are non-destructive (retained partials finalize to the next numbered name); locked .part files never lose their DB owner across cancel/remove/restart; resume claims rows atomically and the queue dedupes ids.
- Stored request headers are re-filtered through the User-Agent/Origin/Referer allowlist on read, URL-derived extensions are sanitized, resume appends never follow symlinks, and transfer errors are logged by message only.
- UI: pause/resume/cancel/retry/remove surface failures in a snackbar; paused items show an active Resume button in VOD/episode detail views; translations for all 18 locales.
- Runtime split into download-runtime/transfer/finalize/broadcast modules; +30 unit tests and an Electron E2E covering pause -> retained .part -> Range/If-Range resume -> byte-exact assembly.
Co-authored-by: genrichh93-ui <genrichh93@users.noreply.github.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Search normalization splits "A&E" into the 1-letter tokens "a" + "e", and
short first tokens are prefix-anchored for performance (GLOB 'a*' plus a
startsWith gate in the ranking), so provider-prefixed channels such as
"US: A&E" could never match.
Words that keep internal punctuation after edge-trimming ("a&e", "x-men",
"l'equipe") are now preserved as compound words and matched as an intact
substring in addition to the existing token logic:
- global Xtream search supplements the unchanged prefix/GLOB arm with a
trigram FTS substring lookup (MATCH '"a&e"'), deduped by content id,
falling back to the content scan if FTS is unavailable
- the ranking gate lets multi-token phrases match as a space-bounded whole-
word sequence anywhere in the title ("US: A&E", score 40) without crossing
word boundaries ("Casa e Villa" stays excluded)
- per-playlist search and the M3U payload prefilter OR-in intact-word
contains patterns
SQL conditions compose per word so a compound word's substring arm stays
AND-constrained by the other words of the query ("A&E HD" cannot flood the
bounded candidate window with plain "A&E" titles); the FTS supplement AND-s
the non-compound tokens as LIKE conditions.
Pure search-text helpers moved into content-search.util.ts.
Fixes#1161
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(epg): harden three latent edges from the #1165 manual-mapping review
Follow-up to #1165 (manual EPG-to-channel mapping). Three minor but real
issues flagged by the bot reviews on #1173, all in already-merged #1165
code rather than the Stalker delta:
1. EPG program dedup ignored source_url. The unique index and upsert key
(channel_id, start, title) collapsed programmes imported from different
XMLTV sources that shared those columns, and the upsert reassigned
source_url to the last importer — so source-scoped queries could miss a
programme and source-scoped deletes could drop another source's row.
The key and index now include source_url (migrated via a _v2 index that
drops the old source-blind one); the upsert no longer overwrites
source_url.
2. Xtream getMapping fallback capped candidate streams at an unordered
first five, so a mapping saved under a later stream sharing the
provider epg_channel_id was silently ignored. Replaced the two-step
fetch-then-lookup with a single content⋈categories⋈mappings join that
finds a mapping under any matching stream, with no arbitrary cap.
3. The Xtream mapping dialog did not refresh after closing, unlike the
Stalker path, so a remapped visible/selected channel kept its stale
preview until the 5-minute TTL or a rescroll. Added
EpgQueueService.invalidate(streamId) and a before/after mapping compare
in the channel-list dialog flow that invalidates the cache and refetches
the current viewport when the mapping actually changed.
Tests: source-aware dedup index/upsert assertions; join-based getMapping
resolution regardless of stream position; EpgQueueService.invalidate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(epg): address review feedback on the #1165 follow-up
- portal-channels-list: forward the already-validated playlistId into the
mapping dialog instead of re-reading currentPlaylist() after the async
getEpgMapping roundtrip (which could return undefined on navigation)
- EpgQueueService.invalidate(): bump a per-stream invalidation epoch and
clear inFlight so a request already running when the mapping changes has
its (pre-change) result discarded via an epoch check in fetchEpg, and the
immediate re-enqueue can schedule a fresh mapping-aware fetch
- getMapping Xtream fallback: order the join deterministically before
limit(1) so the resolved mapping is stable; documented that this backend
layer has no caller playlist context and is a best-effort net behind the
renderer's playlist-scoped resolution
Tests: in-flight staleness discard for invalidate().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(epg): split EpgQueueService invalidation specs under the max-lines limit
The added invalidate() tests pushed epg-queue.service.spec.ts to 415 lines,
over the 400-line ESLint cap (and the baseline must not grow). Moved them to
a focused epg-queue-invalidation.spec.ts; both files are now under the limit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(epg): resolve second-order review findings on the mapping follow-up
Two P2 issues Codex raised on the previous fixes:
- Unscoped program lookups could return the same programme twice now that
the dedup index preserves per-source rows: the M3U timeline calls
getChannelPrograms without sourceUrls, so two sources sharing
channel/start/title both surfaced. Added toEpgProgams(), which collapses
duplicate channel|start|title slots after mapping/validation, applied at
every getChannelPrograms return.
- EpgQueueService.fetchEpg unconditionally cleared the in-flight marker in
its finally, which could drop a marker a re-enqueued request took over
after invalidate(). It now only releases the marker when the completing
request still owns it (epoch unchanged), preserving per-stream dedup and
concurrency accounting.
Tests: unscoped duplicate-slot collapse; stale request preserving a fresh
in-flight marker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(epg): deduplicate program rows in SQL before applying row limits
Codex follow-up: the JS-level slot dedup ran after the SQL LIMIT, so
duplicate cross-source rows consumed the cap and truncated real data.
Moved the dedup into SQL with GROUP BY, applied before the limits:
- selectChannelPrograms / selectLegacyChannelPrograms: GROUP BY
(channel_id, start, title) before ORDER BY start LIMIT 500, so the
timeline cap counts distinct programmes rather than duplicate rows
- selectCurrentProgramsForChannelIds: GROUP BY channel_id before
LIMIT channelIds.length, so duplicate cross-source current slots can't
starve other channels of their current-programme preview
The JS toEpgPrograms() dedup stays as a safety net (e.g. legacy NULL-source
rows the unique index treats as distinct). Test query-chain mocks updated
for the new groupBy link.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(epg): manual EPG-to-channel mapping with mapping fallback in all EPG paths
* fix: epg mapping in live tv list
* fix(epg): harden manual EPG mapping — upgrade safety, perf, playlist-scoped keys
Follow-up fixes on top of the manual EPG-to-channel mapping feature:
- epg-database: dedupe existing epg_programs rows before creating the
unique (channel_id, start, title) index — a plain CREATE UNIQUE INDEX
crashed the EPG worker on upgrade when historical duplicates exist;
replace INSERT OR REPLACE with ON CONFLICT DO UPDATE so the
epg_programs_fts delete trigger is not bypassed (REPLACE skips delete
triggers unless recursive_triggers is on), with a plain-INSERT
fallback when the index cannot be created
- db: add idx_content_epg_channel — the mapping fallback scanned the
whole content table on every single-channel EPG lookup
- keys: scope Xtream mapping keys per playlist via shared
buildXtreamEpgMappingKey (xtream:{playlistId}:{id}) — bare stream ids
collide across portals; the backend fallback now joins categories to
resolve the playlist id
- pwa: hide "Map EPG channel" entries behind the supportsEpgMapping
capability — the menu item was a dead end in the PWA
- parser: parse the XMLTV offset sign from the string — Math.sign(0)
dropped the minutes of ±00:xx offsets
- cleanup: typed window.electron access instead of ad-hoc casts, drop
unused resolveChannelId and dialog data field, shared
EpgMappingDialogComponent.open() for all seven call sites
- dialog UX: minimum-characters search hint, save/remove snackbars,
current mapping shows the EPG channel display name,
takeUntilDestroyed on the search stream
- i18n: fill the new keys in all 17 locales
- tests: cover mapping CRUD/search escaping, the dedup-index guard,
offset parsing and playlist-scoped keys; update stale stream-resolver
specs for the new 50-item limit and 10s timeout
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(epg): escape backslashes in EPG channel search LIKE pattern
CodeQL js/incomplete-sanitization: a lone trailing backslash in the
search term paired with the closing wildcard under the ESCAPE clause
and corrupted the pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(epg): batch mapping lookups in the viewport preview queue
Expose the existing getEpgMappingsBatch operation over a new
EPG_MAPPING_GET_BATCH IPC channel and use it in resolveManualMappings —
the per-entry lookup issued one IPC round-trip per visible channel on
every scroll event.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(epg): batch mapping prefetch in the collection preview loader
Resolve all candidate mapping keys for an Xtream preview batch with a
single getEpgMappingsBatch IPC call instead of per-channel lookups.
Also fix a worker early-exit: a channel without tvgId/name returned out
of the shared-iterator loop and silently killed one of the three
concurrent preview workers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <serega05@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>