Commit Graph
90 Commits
Author SHA1 Message Date
4gray 26b820cc29 Merge remote-tracking branch 'origin/master' into claude/vod-multi-source-49a8ba 2026-07-29 00:10:35 +02:00
4grayandClaude Opus 5 99a85da6b0 feat(packaging): register IPTVnator as the .m3u/.m3u8 handler (#1301)
* 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>
2026-07-28 21:53:43 +02:00
4grayandClaude Opus 5 f80eb4d1b9 fix(playlist): open playlists handed over by the OS (#1299)
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>
2026-07-28 20:28:29 +02:00
4grayandClaude Opus 5 9deb4325e2 feat(portals): carry VOD source pins through playlist backup
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>
2026-07-28 19:17:39 +02:00
4grayandClaude Opus 5 7cce227268 fix(portals): do not spend a source's failover turn on mere selection
`setActiveSource` marked the source tried, but discovery calls it the moment
the page opens and a pin or the picker can call it before anything plays. So
opening a movie burned the route copy's turn: if a pinned alternative then
failed, failover skipped a healthy untouched source — and with only one
alternative, reported the options exhausted.

Selection and attempt are now separate. `setActiveSource` selects;
`markPlaying` also spends the turn, and only the three places that really
start playback call it. `runFailover` additionally retires whatever is on
screen before picking, so the failing source is spent however it got there —
relying on the start paths alone would leave one hole per path, and the cost
of missing it is a ping-pong between two sources.

One existing spec asserted the old behaviour (a route copy burned by a switch
it never played); it now plays first, so it still covers what it meant to —
that the tried set survives a rediscovery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:40:08 +02:00
4grayandClaude Opus 5 cf4a342193 fix(portals): say "Playing" only while something is playing
`isActive` means "the source a switch or Play would use". Discovery sets it
the moment the page opens and it survives closing the player, so it could not
back the two claims the UI made in the present tense: the "Playing from"
caption and the source row's Playing badge. Both appeared on a page where
nothing had started, and came back after the player was closed.

`playbackLive` is now that statement, and both read it. Inline it needs a
timeupdate — `inlinePlayback()` is only the REQUEST to play, non-null while
the engine is still opening the stream and still non-null after it fails —
and external it needs the session past `launching`. A row that is merely
selected reads "Current" (new key, filled for all 19 locales).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 10:26:28 +02:00
4grayandClaude Opus 5 56733db1ec fix(portals): gate VOD auto-failover on engines that can report failures
Merges master and resolves the collision with its shared playback helpers,
then closes two Codex findings.

Master extracted the Play/Stop button state and the inline position writer
that this branch had modified. Rather than forking private copies back out of
Xtream, both behaviours move into the shared helpers: `alsoOwns` lets a page
own an external session launched for a copy of the same film in another
playlist, and the resume latch — which stops a timeupdate emitted before the
engine reaches `startTime` from overwriting the point being resumed from —
now protects Stalker too, which had the same bug.

Auto-failover was offered on every engine, but only the built-in web players
raise the playback diagnostic that reaches `onPlaybackFailed()`: Embedded MPV
has its diagnostics suppressed and MPV/VLC play outside the app. The toggle is
now hidden there, in settings and in the sources menu, instead of promising a
switch that can never happen.

`setAutoFailover` also ignored `updateSettings()`, which patches memory first
and rejects if the write fails — the toggle looked saved, silently reverted on
restart, and the rejection surfaced only as an unhandled promise. It now
reports the failure like the settings form's own save paths.

The three VOD-details route specs each carried a near-identical 150-line
TestBed; they now share one harness, which is what makes room for the new
cases (1012 -> 584 lines).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 10:20:02 +02:00
4grayandClaude Opus 5 9f0a8b4f19 Merge master, dedupe the kept copy, and retire superseded switches
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>
2026-07-28 08:44:49 +02:00
4gray a2fafcfc08 test(performance): add end-to-end Xtream benchmark harness (#1300)
* docs(performance): plan Xtream benchmark

* feat(xtream-mock-server): add deterministic 100k fixture

* style(xtream-mock-server): apply repository formatting

* fix(xtream-mock-server): harden performance fixture data

* feat(xtream-mock-server): add performance control plane

* docs(performance): correct Xtream capture plan

* fix(xtream-mock-server): harden performance controls

* fix(xtream-mock-server): harden control lifecycle

* feat(performance): add Xtream preload markers

* feat(performance): trace Xtream main phases

* feat(performance): mark Xtream store publications

* feat(performance): trace Xtream database phases

* feat(performance): trace Xtream delete cancellation

* feat(performance): capture Xtream phase attribution

* feat(performance): mark Sources Xtream refresh

* test(performance): define Xtream benchmark evidence contracts

* test(performance): add Xtream benchmark runner

* test(performance): surface failure evidence writes

* test(performance): align database read clock

* test(performance): preserve capture failure contracts
2026-07-28 08:08:07 +02:00
4grayandClaude Opus 5 ce1b1f3269 fix(portals): keep a pinned play, a same-playlist copy and Check honest
Five findings from the latest review pass.

Reading a pinned source's own position is a database round-trip, and the user
can navigate across it — the continuation then handed one film's source id to
whichever movie now owned the screen. Guarded, like every other await here.

Allowing a pinned copy to live in the current playlist made "is this the
route's own source?" a two-part question, and the ownership check still asked
only about the playlist: an external session for that copy was disowned, so
Stop vanished and its progress was dropped.

The yearless title alias is shared by every remake, so clearing every alias
before a write could delete a different film's pin. Writes and unpins now
touch only keys that name one film — plus the ambiguous row this session
actually read, which is the one the user is looking at and the one whose
absence would make an unpin come back.

Restart left the seeded position in the controller, so a failure before the
first timeupdate resolved the next source back at it.

And the alternative rows on the playback-error screen had a Check button
wired to nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:05:05 +02:00
4grayandClaude Opus 5 f0c99fac87 fix(portals): stop a remake matching, and let a pin survive its own playlist
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>
2026-07-28 02:28:26 +02:00
4grayandClaude Opus 5 ed442da3ca fix(portals): re-check the movie after waiting, and follow the alternative
Three findings, two of them regressions from the previous round.

Awaiting a pending discovery before failover let the user navigate during
that wait: the continuation then ran against whatever controller was current
and could answer one film's playback failure by starting another film's
alternative. Both waits — failover and pinned Play — now re-check that the
same movie still owns the screen.

Pinned Play also needed the wait it did not have. Pressing Play while the
pin lookup was still out concluded "nothing is pinned" and started the
route's own source, making a persisted preference depend on worker latency.

And the position bridge still accepted only the route's ids, so an external
player running an alternative had every progress update discarded: the
resume point stayed where playback began and a switch an hour in rewound the
lot. The session matcher and the bridge now share one ownership predicate,
since a page that shows Stop for a session whose progress it throws away is
the bug in two halves.

The test for the external case previously set the position signal directly,
which bypassed the very filter that was broken; it now drives the bridge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 01:52:43 +02:00
4grayandClaude Opus 5 b1ad9657c3 fix(portals): probe like playback, and stop the pin answering for a remake
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>
2026-07-28 01:16:56 +02:00
4grayandClaude Opus 5 e3465d3a45 fix(portals): make every alias of a pin agree, and stop losing rows
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>
2026-07-28 00:01:05 +02:00
4gray bfad82c26c fix(settings): stop settings silently reverting on restart (#1272)
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 #1156
Closes #102
2026-07-27 23:14:30 +02:00
4grayandClaude Opus 5 df30262f24 fix(portals): keep external playback, the pin and the resume point honest
Five findings from the round-5 review.

An external player launched for an alternative carries that playlist's ids,
so the page disowned its own session: the primary button never became Stop,
stopping found nothing to stop, and another click opened a second player.
Multi-source now tells playback which source is actually active, and the
matcher accepts either that or the route's own stream.

Stop also has to beat the pin. The primary action consults the pin first —
that is what makes a pin decide where playback starts — but while a session
is running the same button reads Stop, and consulting the pin there made the
control do the opposite of its label.

A pinned source started from the Resume button resolved at zero, because
nothing reports a live position until the first timeupdate. The controller is
now seeded from the persisted position, one-way: a live value always wins,
since the stored one lags it and applying it would rewind.

A pin whose write failed was still shown as pinned, promising a preference
that reopening the movie would not have.

Portal failures in this path logged raw errors. An Xtream error message
carries the stream URL, and that URL is built out of the username and
password, so they now go through the redacting logger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:07:26 +02:00
4grayandClaude Opus 5 0334296f15 fix(electron-backend): make test suite and lint host-agnostic on Windows checkouts (#1176)
* 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>
2026-07-27 23:05:22 +02:00
4grayandClaude Opus 5 d3e337261e fix(portals): stop a pin write from landing on the next movie
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>
2026-07-27 21:40:34 +02:00
4grayandClaude Opus 5 93caa1c5a6 fix(portals): stop the source list from losing the real alternatives
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>
2026-07-27 21:25:07 +02:00
4gray 24f0dee6f0 test(performance): add formal M3U import benchmark (#1287)
* test(performance): add formal M3U import benchmark

* test(performance): harden formal capture validity

* test(performance): address benchmark review feedback
2026-07-27 10:34:22 +02:00
4grayandClaude Opus 5 a715e05a09 docs(portals): record the behaviour the review rounds changed
The architecture doc and CLAUDE.md described the feature as first written, not
as it now behaves: pins were documented as a stored preference without saying
they decide playback, failover was described as stopping at the first
unresolvable candidate, the probe as HEAD-only, and discovery as pure FTS with
no mention that short titles cannot be tokenized at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:30:15 +02:00
4grayandClaude Opus 5 004cb65fe6 feat(portals): find the same movie in your other playlists
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>
2026-07-27 08:34:56 +02:00
4gray e55d55b47f feat(mock-data): add shared screenshot-safe poster catalog (#1271)
Moves the fictional movie catalog into `libs/shared/marketing-fixtures` so the
Xtream and Stalker mocks describe the same titles, and adds 20 rendered posters
plus the shared fixture types behind them.

Supporting changes made while getting it green:

- `shared-marketing-fixtures` is classified Tier B in the coverage policy. Not
  Tier A: it is fictional fixture data, so a statement percentage over it means
  nothing, and a Tier A entry would pull it into the merged coverage map and the
  ratchet. Tier B still runs its spec in CI. `stalker-mock-server` needs no entry
  of its own — it is already Tier C and the Tier B/C runner falls back to
  `pnpm nx test <project>`, so its new `marketing-poster-url.spec.ts` runs.
- Two release-capture defects the catalog reorder introduced, both fixed in
  `tools/release/capture-app-driver.ts`:
  - VOD stream ids are `MARKETING_VOD_STREAM_ID_BASE + index` and the generator
    now lists the showcase movies first, so 62000-62002 became Black Harbor, The
    Paper Astronaut and Summer Static while the dashboard seeding still mapped
    those ids to the previous titles' backdrops.
  - the raw `tsx` spawn of the Xtream mock lacked `--tsconfig
    tsconfig.base.json`, so the mock could not resolve
    `@iptvnator/shared/marketing-fixtures` and the capture never started. Both
    mock projects' own serve targets already passed the flag.
2026-07-27 08:10:57 +02:00
4gray e1c4853a39 Merge pull request #1280 from 4gray/agent/perf-exact-process-memory
fix(perf): make process memory captures comparison-safe
2026-07-27 02:57:02 +02:00
4gray 636545cbb7 refactor(electron-backend): split four files under the max-lines limit (#1278)
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.
2026-07-27 02:16:02 +02:00
4gray 554eecfee0 fix(perf): finalize exact worker capture safely 2026-07-27 02:15:59 +02:00
4gray da3b657277 fix(perf): make worker profiling request scoped 2026-07-26 22:37:52 +02:00
4gray e3ce60a35e chore(electron): add preload performance markers 2026-07-26 22:37:52 +02:00
4gray 9ae53e4515 fix(playback): make the "Show subtitles" setting reach the web players (#1269)
The persisted subtitle preference only ever had an owner behind the default-off
shared web-controls flag, so with the shipping controls it did nothing: Video.js
never read it, ArtPlayer declared the input but never used it, and the HTML5
player only ran a one-shot pass after play() resolved — before hls.js had added
its text tracks. No portal host bound the input at all, so it never reached
Xtream or Stalker pages either.

Extract the source-local track controllers into the adapter-free
WebVideoSourceTracks and have WebVideoSourceControlsBridge wrap it, so both
controls modes apply the preference through the same code. The preference-off
players bind it directly (VjsLegacyTracks for Video.js), and
WebPlayerViewComponent reads the preference from SettingsStore instead of an
input so every host inherits it.

The preference means different things depending on who renders the caption UI:
shared controls stay authoritative for the session, while vendor chrome is
source-default — the preference seeds each new source and is released once the
media reports playing, so the engine own caption menu keeps working. Mode
selection is an optional playbackStarted probe passed to the HLS, native and
Shaka helpers; in that mode the HLS helper deselects the track rather than
hiding it, since subtitleDisplay would silently override the vendor menu.

Closes #1155
2026-07-26 09:26:40 +02:00
4gray f147d4fe37 perf(m3u): stop cancelled refresh workers (#1268) 2026-07-26 09:25:51 +02:00
4gray 3032cfa88d fix(m3u): avoid persisting hydrated favorites (#1232) 2026-07-26 02:20:32 +02:00
4grayandClaude Opus 4.8 a5bb8dc25c fix(tmdb): series cast was the latest season only, not the show (#1242)
* fix(tmdb): series cast was the latest season only, not the show

TMDB documents a TV id's `credits` as the credits of the LATEST SEASON.
We requested exactly that and rendered it as "the cast", so every
long-running show lost every regular who had left: The Boys showed
whoever appears in the newest season, not the ensemble.

The TV details request now also appends `aggregate_credits`, which spans
the whole run — but per TMDB omits the newest season, so neither payload
alone is the cast. `unifiedTvCast` unions them: whole-run billing order
first, then people who appear only in the newest season, deduplicated by
person id. Characters come from the aggregate `roles[]` shape.

Deliberately NO cache-key bump. Rows cached before this simply lack
`aggregate_credits` and keep the previous behaviour until they expire,
which avoids invalidating every user's details cache twice — the roadmap
schedules one consolidated bump once the remaining append_to_response
additions (images, certifications, alternative_titles) land together.

Movies are untouched: /movie/{id} has no aggregate_credits and its
`credits` is already the full cast.

Tests: departed regulars retained, newest-season arrivals appended after
show billing order, characters read from roles[], no duplicates across
the two payloads, graceful fallback for pre-aggregate cache rows.

Refs docs/architecture/tmdb-roadmap.md A2.

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

* fix(tmdb): reserve cast slots so newest-season arrivals survive the cap

The union was appended aggregate-first and then truncated to ten, so on
exactly the shows it was built for — long-running ones, where the
whole-run cast alone exceeds the limit — every newest-season arrival was
sliced back off. The original fixture had two aggregate members and
could not catch it.

unifiedTvCast now holds back up to three slots for the top-billed
arrivals instead of appending them where the cap discards them, and
gives the slots back when nobody is new.

Tests: a 12-member aggregate plus two arrivals keeps both arrivals and
top billing; an aggregate with no arrivals still gets all ten slots.

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

* test(tmdb): split the series-cast suite out of the merge spec

The merge conflict resolution put both new describes back into
tmdb-merge.spec.ts, pushing it to 499 lines — past the 400-line
max-lines cap. The aggregate-credits suite moves to its own file.

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

* fix(tmdb): stop the cast union from shrinking, and bound what it caches

Three follow-ups from a review pass over the aggregate-credits union:

- The reserved arrival slots were subtracted from the aggregate even when
  the aggregate was shorter than the cap, so a show with four regulars and
  five newcomers returned seven names instead of nine. The reservation is
  a floor for arrivals now, not a quota.
- An aggregate member's character came from the first role with any text,
  so a one-episode cameo could outrank the part the actor is known for.
  Pick the role with the most episodes.
- aggregate_credits carries a show's whole-run cast AND crew, and details
  payloads are cached verbatim — orders of magnitude of JSON for a list
  the merge truncates to ten people. Cache the billing-order prefix and
  drop the crew nothing reads.

Extracting the people-related helpers into tmdb-credits.ts keeps
tmdb-merge.ts under the line cap.

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

* fix(tmdb): keep the aggregate ids the arrival check depends on

Trimming the cached cast to its top 40 broke the property it was supposed
to preserve: `known` is built from the aggregate ids, so a returning actor
billed below the cut read as a new arrival on the cached path and took a
reserved slot. The same show then showed a different top ten on its second
open than on its first.

Keep the whole cast, and cut the two things nothing reads instead: the
aggregate crew, and every `roles[]` entry except the one the merge picks
(most episodes). A merge over the trimmed payload now provably returns
what a merge over the full one does — covered by a test that runs both.

Also points CLAUDE.md and the doc's module table at tmdb-credits.ts, where
the credit helpers now live.

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

* docs(tmdb): add the release note for the series-cast fix

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

* fix(tmdb): let the cache trim reuse the merge's own role choice

The trim picked the role with the most episodes; the merge picks the
NAMED role with the most episodes. TMDB uses unnamed roles for uncredited
appearances, so a member whose blank role outranked their real one lost
their character on every render after the first.

Both now call pickAggregateRole, which is the point — two copies of the
same choice are what let them drift.

Also adds a test pinning the property the earlier truncation defect broke:
the displayed cast is the cap or everyone available, whichever is smaller.
Which people make the cut at the cap is the reservation's job and is
deliberate; the count is not negotiable.

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

* docs(tmdb): state the aggregate-credits contract as TMDB actually words it

TMDB describes the endpoint in one sentence that contradicts itself: "it
does not return the newest season. Instead, it is a view of all the entire
cast & crew for all episodes belonging to a TV show." The doc and the code
comment asserted the first half as settled fact.

The union never depended on that reading — arrivals are a set difference,
so under "whole run" they are simply empty — but the comment implied an
assumption the code does not make. Say what TMDB says, note the ambiguity,
and note why either reading is safe.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:55:44 +02:00
4grayandClaude Opus 4.8 0b967d66d4 feat(tmdb): metadata cache panel with a clear button in settings (#1244)
* 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>
2026-07-26 01:13:02 +02:00
4grayandClaude Opus 4.8 d76b2d2a57 fix(tmdb): stop a broken provider tmdb_id from suppressing enrichment (#1239)
* fix(tmdb): stop a broken provider tmdb_id from suppressing enrichment

Providers ship dead and stale tmdb_id values, and enrich() trusted them
unconditionally:

    parseProviderTmdbId(query.tmdbId) ?? await resolveIdBySearch(...)

A garbage-but-integer id short-circuited the title search entirely. The
details fetch then 404'd, the outer catch swallowed it, and the item was
left permanently unenriched — no plot, no cast, no artwork — for a title
the search would have matched. Failed detail fetches cache nothing, so
the wasted request repeated on every re-open. The stale-but-valid case
was worse: it never threw, nothing sanity-checked the resolved title, and
we confidently rendered another film's metadata.

enrich() now treats the provider id as a hint. If it fails to resolve, or
resolves to something whose title matches none of the search variants we
would have queried, the confidence-gated title search gets its turn — and
proven-bad ids are negative-cached (7d, language-independent row) so the
404 is not repeated forever.

Deliberately NOT a hard rejection on title mismatch: TMDB returns titles
in the REQUEST language, so a Russian provider title legitimately fails
the name check against an en-US payload. A mismatch only lets the search
compete; when the search finds nothing confident, the provider payload is
kept. The change can therefore only add enrichment, never remove it.

Extracts the search resolution and the bad-id cache into
TmdbIdResolverService — tmdb-enrichment.service.ts was at 290 lines
against the 300-line target, and the resolver is independently testable.

Tests: new tmdb-enrichment.service.spec.ts covers the happy path issuing
exactly one details call and no search, 404 fallback, stale-id override,
the keep-the-payload safety property, bad-id skip, and the no-match case;
matcher spec covers detailsMatchProviderTitle and the namespaced cache key.

Refs docs/architecture/tmdb-roadmap.md A1.

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

* fix(tmdb): only blame a provider id when TMDB confirms it does not exist

Review found the bad-id negative cache too eager in two ways, both of
which could deny enrichment to items whose id was fine.

1. Any failure recorded the verdict. A 401, 429, 5xx or an offline blip
   would mark a perfectly valid id as dead for seven days, so after the
   service recovered — or the user fixed their API key — titles that the
   search cannot resolve confidently stayed unenriched until the marker
   expired. TmdbApiService now throws a typed TmdbApiError carrying the
   status, and only a confirmed 404 is recorded.

2. Title mismatches were recorded too. That id EXISTS; it is merely wrong
   for this item. The row is keyed by id alone and shared across
   playlists, so a stale mapping on one item disabled the direct lookup
   for every other item that legitimately used the same id. Mismatches
   are no longer cached at all — the search verdict is cached anyway, so
   the repeat cost is a single details fetch.

Documents the row kind in the cache contract, which listed only two of
the (now six) lookup_key shapes.

Tests: 404 records, 429 does not, network error does not, mismatch does
not.

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

* fix(tmdb): keep provider details when the competing search fails

detailsForProviderId only runs the search to see whether it can beat a
title-mismatched provider payload. A throw from that best-effort search
(offline, rate limit, 5xx) propagated to enrich()'s outer catch and threw
away details we already had — the searched-details fetch right below it
was already tolerant. Fail to the details in hand instead.

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

* fix(tmdb): decide a suspect provider id on evidence, not on the title

The title check alone was both too weak and too dangerous.

Too weak: normalizeTitle strips trailing years, so "Blade Runner 2049"
carrying the 1982 film's id matched and the wrong film was rendered —
exactly the stale-id case this was meant to catch.

Too dangerous: an ALL-CAPS leading token reads as a language tag, so
"IT - Chapter Two" normalizes to "chapter two". The correct payload
failed the name check, and a year-less search for "chapter two" would
confidently return the 1979 film and overwrite it. Master trusted the
provider id here and got it right.

assessProviderId weighs both signals: title or year agrees means use the
details; both years known and incompatible means the search may take
over; a title-only mismatch is inconclusive and keeps the details. The
search branch now always has a year, so its own gate corroborates
whatever it returns instead of matching on name alone.

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

* fix(tmdb): do not search after a transient provider-id failure

enrich() reads a null from detailsForProviderId as "the id is unusable,
try the search". A 401/429/5xx/offline failure gave it that null, so an
outage turned into a second request that would fail too — and if it did
come back, a title match replaced a provider id that was probably fine.
Only a 404 falls through to the search now; everything else rethrows and
leaves the id retryable.

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

* docs(tmdb): add the release note for the provider-id fix

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:34:17 +02:00
4grayandClaude Opus 5 4e5132cbb5 ci(release): gate PRs on an authored release note (#1257)
* ci(release): gate PRs on an authored release note

Second slice of the release-notes pipeline (#1256 landed the format and
generator): make the .changes/ habit survive contact with reality.

- "Release note gate" job in ci.yml, PR-only: validates every .changes/*.md,
  then requires an added note (or the no-release-note label) when the PR
  touches runtime code under apps/ or libs/. Tests, e2e projects, the
  website, mock servers, shared testing helpers, snapshots and docs are
  auto-exempt.
- Policy lives in tools/release/check-release-note-gate.mjs as a pure
  function fed PR files+labels as JSON — unit-tested (10 cases) instead of
  encoded in workflow bash. The failure message lists the triggering files
  and names the exact fix.
- Labels are fetched live rather than from the stale event payload, so
  applying the label and re-running the check works without a new push.
- The job is dependency-free Node: no pnpm install, runs in seconds.
- release-notes and release-cut skills added under .claude/skills/ and
  mirrored to .codex/skills/; CLAUDE.md/AGENTS.md sections updated to point
  at the gate and the skills.

The no-release-note label itself was created in the repository.

Tests: 47 passing in release-tools (10 new gate cases); gate-step shell
verified with shellcheck at the CI severity; ci.yml YAML-parse checked.

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

* chore(agents): make the release skills discoverable by Claude Code too

`.codex/skills/**` was un-ignored so Codex picks up repository skills in any
clone, but `.claude` was ignored wholesale — and Claude Code only discovers
skills under `.claude/skills/`. The release-notes and release-cut skills
therefore existed only on whichever machine authored them.

Mirror both skills into `.claude/skills/` and opt them in by name rather than
un-ignoring the directory: contributors keep personal skills there
(i18n-fill, website, …) which must stay local and out of `git status`.

CLAUDE.md/AGENTS.md updated so the "skills live under .codex/skills/" claim
does not go stale, including the requirement to keep mirrored copies in sync.

The CI gate and the CLAUDE.md/AGENTS.md section remain the load-bearing
enforcement; skills only carry the detail.

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

* fix(ci): only a note this PR authored satisfies the release-note gate

Review follow-ups on #1257 (Codex P2 ×2, Greptile P1).

- Drop `renamed` from the accepted statuses. The PR files API compares
  base…head, so a note created and then renamed inside the same PR still
  reports as `added`; a `renamed` entry means the file already existed on the
  base branch. Accepting it let a runtime-code PR pass by moving another
  PR's unconsumed note, which documents nothing and gives the generator no
  adding commit to resolve a PR link from.
- Require a direct child of `.changes/`. `loadNotes()` reads only the
  immediate directory, so `.changes/sub/note.md` satisfied the old prefix
  check while never being validated or rendered into any release surface.

Tests: renamed and nested notes now assert a failing gate (12 gate cases).

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:51:04 +02:00
4grayandClaude Opus 5 270350c2e1 chore(release): author release notes in .changes instead of reconstructing them (#1256)
* chore(release): author release notes in .changes instead of reconstructing them

CHANGELOG.md has been frozen at 0.12.0 since 2023 while the app shipped
0.23.0, semantic-release sat in devDependencies with no config, and the real
user-facing notes were a 280-line MDX post written from memory at release
time. The gap was never version math — it was authored notes captured while
the context is still fresh.

Add a `.changes/*.md` note format (type, area, issues, screenshot; no version
field, since the release version is chosen deliberately) plus a generator that
composes the GitHub release body, the CHANGELOG.md section and a blog-post
scaffold from the accumulated notes.

Changesets was considered and rejected: it versions multiple published
packages, and this repo has exactly one private package. Its `version` step
would also rewrite CHANGELOG.md into a flatter format than the blog post and
fight the deliberate, updater-constrained version choice.

- hand-rolled frontmatter parser over a YAML engine: the schema is closed, so
  it can reject unknown keys, which is what catches typos
- PR numbers are resolved from the commit that added the note, never written
  by the author
- MDX-significant characters in note bodies are escaped so a stray `<` cannot
  break the website build
- blog scaffold ships `draft: true` with explicit TODO headings; the prose is
  editorial work, only the inventory is mechanical
- revive CHANGELOG.md with an honest pointer for 0.13.0-0.23.0 rather than
  fabricating the missing history
- drop the five unused semantic-release/conventional-changelog packages

Docs: `.changes/README.md`, plus a "Release Notes For User-Visible Changes"
section mirrored in CLAUDE.md and AGENTS.md, and a PR template checkbox for
contributors who never read either.

Tests: 26 unit tests in tools/release/release-notes.test.mjs covering parsing,
validation, grouping and all three renderers.

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

* fix(release): default the notes version to package.json and harden alt escaping

Review follow-ups on the release-notes generator.

- `--version` now defaults to the root package.json version, so the
  `release🎶*` package scripts run bare instead of failing on a missing
  argument. Bumping package.json is the deliberate act that starts a release,
  which makes it the right single source of truth; `--version` remains as an
  override for dry runs before the bump. The notice goes to stderr so
  `--format github` keeps a pipeable stdout.
- Escape backslashes before apostrophes when building the MDX `alt` string
  literal. A note body ending in a backslash previously produced an
  unterminated string and would have broken the website build.
- Document that release posts are one per minor version, in the slug helper,
  the overwrite error, and `.changes/README.md` — a patch release edits the
  existing post rather than creating a second one.

Tests: +1 regression test for the alt escaping, verified to fail without the
fix (27 total, all passing).

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

* feat(ci): put authored notes into the tag release body, fail-closed

Wires the .changes pipeline into the release workflow (Codex review P1 on
#1256). Calling the generator from the tag build cannot work — --consume
deletes .changes/ before the tag exists — so the tag build reads what the
generator already wrote: release-meta now fills BODY from the CHANGELOG.md
section matching the tag's version via tools/release/extract-changelog-section.mjs.

generate_release_notes stays on, so GitHub's commit list renders below the
authored notes; the existing draft-metadata repair step already concatenates
RELEASE_BODY with the generated notes, so the rare duplicate-draft path keeps
the same layering unchanged.

The extractor exits non-zero when the section is missing or empty, failing
the release instead of silently shipping PR-title-only notes. A hotfix tag
cut without running release:notes:changelog therefore fails at create-release
by design; the error message names the exact commands to run.

Tests: 5 new extractor tests (32 total in release-tools, all passing);
packaging suite (247) re-run green since build-and-make.yaml is one of its
inputs.

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

* fix(release): escape all regex metacharacters in the changelog extractor

CodeQL flagged the version-to-RegExp interpolation in
extract-changelog-section.mjs (regex injection + incomplete escaping): only
dots were escaped, and while the CLI validates its argument as bare semver
before calling, the exported extractSection() carries no such guarantee on
its own. Escape the full metacharacter set so no caller can inject pattern
syntax, with tests covering wildcard dots, alternation, `.*` and backslashes.

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

* fix(release): make changelog generation idempotent per version

Codex review P2 on #1256: rerunning `release:notes:changelog` for the same
version — the normal move after correcting a note before --consume —
prepended a second section instead of replacing the first, leaving duplicate
release entries.

Extract the marker insertion into upsertChangelogSection(): it removes any
existing section for the version, then rebuilds around the marker rather than
string-replacing into it, so the blank-line count on both sides stays exact
on both the fresh-insert and replace paths. The CLI reports when a section
was replaced.

Tests: 4 new cases (insert, replace-not-duplicate, neighbours untouched,
missing marker); 37 total passing. End-to-end rerun verified: one heading,
latest date wins, extractor output unchanged.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 18:22:43 +02:00
4grayandClaude Opus 4.8 8e1320cb34 feat(tmdb): series production-status chip and person death dates (#1240)
Two fields TMDB already sends us and the merge threw away — no new API
calls, no cache-key bump, they light up on existing cached payloads.

Series detail views (Xtream and Stalker) gain a production-status chip:
"Ended" tells you a show is finished before you commit to it, "Returning"
that it is not. TMDB returns `status` as an ENGLISH string even under
language=ru-RU, so it is normalized to a stable token
(normalizeSeriesStatus) and rendered through translated labels
(seriesStatusLabelKey). Unknown values are dropped rather than shown, so
a status TMDB adds later can never leak raw English into 19 locales.

Person pages render `deathday`, which mapPersonProfile has always parsed
into ActorProfile and no template ever read.

i18n: 7 keys across all 19 locales via the tools/i18n workflow.
Tests: status normalization (token mapping, case-insensitivity, the
British "cancelled" spelling, unknown/missing dropped).
Docs: tmdb-metadata-enrichment.md, CLAUDE.md.

Refs docs/architecture/tmdb-roadmap.md C1 and the zero-extra-call tier.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:50:56 +02:00
4gray cfa602d5b1 ci: cut PR runner waste and harden workflow permissions (#1226)
Pipeline audit follow-up: reduce wasted runner time on PRs and tighten CI
security, without reducing what actually gets validated.

Runner-time waste:
- Concurrency with PR-only cancel-in-progress on CI, E2E, and docker-build,
  so a new push cancels the previous commit's still-running checks. Non-PR
  runs use the unique run_id as the group, because GitHub keeps at most one
  pending run per group even with cancel-in-progress: false — a shared ref
  group could silently drop a queued master run.
- paths-ignore for docs-only changes (Markdown, docs/, .plans/, .codex/,
  .claude/) on the Electron build matrix and the E2E suites; E2E also skips
  apps/website/**. The build workflow keeps apps/website/** because its Linux
  job builds the website to verify AppStream assets. Tag pushes are
  unaffected: GitHub does not evaluate paths filters for tags.
- PRs lint affected projects only; master pushes keep the full run-many.
  Lint-global inputs (eslint.config.mjs, tools/eslint/**) now mark all 41
  lint projects affected, including the run-commands targets database and
  packaging, so the max-lines baseline cannot be widened without lint.

Hardening:
- Explicit least-privilege permissions on CI, E2E, and build-and-make; the
  create-release job keeps its job-level contents: write. The repository
  default workflow token was switched to read-only.
- New actionlint job (image pinned by digest, shellcheck at warning+), with
  the shared-anchor false positive suppressed in .github/actionlint.yaml.
  Fixed one real finding: unquoted $GITHUB_OUTPUT.
- .github/dependabot.yml: weekly cadence, minor+patch grouped per ecosystem
  (npm, GitHub Actions, Docker), majors stay individual PRs.

Docs updated: CLAUDE.md, docs/architecture/nx-workspace-boundaries.md, and
docs/architecture/validation-map.md now describe affected-lint on PRs and the
E2E path-filter exceptions.
2026-07-25 14:37:40 +02:00
4grayandClaude Opus 5 4ca2b6852e feat(playback): Up Next episode rail for the inline series player (#1231)
* feat(playback): Up Next episode rail for the inline series player

On wide windows the inline series player now docks left and fills the
leftover stage column with a Netflix-style "Up Next" rail: the rest of the
current season plus next-season spillover, the playing episode highlighted,
and watch-progress bars from playback positions. Clicking an episode plays
it inline through the host's existing episode flow (Xtream serial-details
and Stalker series view).

- New app-up-next-rail component + buildUpNextRailItems() util in
  ui/playback; entries carry the host's raw episode object so selection
  needs no id lookup.
- PortalInlinePlayerComponent measures the theater stage with a
  ResizeObserver and docks the rail only when the leftover beside the 16:9
  player is >= 320px; narrower stages keep the centered theater/ambient
  behavior from #1223. Movies and live never show the rail.
- New playerUpNextRail setting (Settings > Playback, default on, built-in
  web players only), mirroring playerAmbientMode; enforced at runtime for
  non-web engines.
- i18n: SETTINGS.PLAYER_UP_NEXT_RAIL(+_DESCRIPTION) and PORTALS.UP_NEXT in
  all 18 locales.
- The rail renders as an opaque panel on top of the stage, so the ambient
  fill stays behind it.

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

* fix(playback): address Greptile review on the Up Next rail

- Stage overflow: `.player-shell__viewport` had no border-box sizing (the repo
  has no global reset), so the docked-rail modifier's 12px padding widened the
  stage past its container and the right edge was clipped.
- Width gate: compute the width the rail actually receives (stage minus the
  docked layout's padding, the height-driven 16:9 player, and the flex gap)
  instead of raw stage slack, and observe the stage's border box so the
  modifier's own padding cannot feed back into the measurement.
- Stalker lazy seasons: Ministra VOD-series seasons hold no episodes until
  opened, so the rail's next-season spillover stopped at the current season.
  Prefetch the following season while an episode plays inline.

Adds regression coverage for the gate boundary, gate stability across the
padding toggle, and the lazy-season prefetch.

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

* fix(stalker): stop the rail spillover prefetch from retrying forever

A failed or genuinely empty Ministra season resets isLoading while leaving
episodes empty, so the prefetch effect re-requested the same season on every
emission for as long as inline playback continued. Remember which seasons this
view already requested and ask at most once each.

Regression test asserts the empty-response case fetches exactly once and does
not retrigger on further playback in the same season.

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

* fix(stalker): let a failed spillover prefetch recover on the next episode

The previous guard was permanent, so a transient network or authorization
failure disabled the rail's next-season prefetch for the component's lifetime.
Distinguish the two outcomes instead:

- Answered (even with zero episodes) — a real answer, never asked again.
- Failed — the claim is released, but pinned to the episode that triggered it,
  so the retry waits for the next playback change. Retrying immediately would
  loop, since the failure itself flips isLoading and re-runs the effect.

The claim is taken synchronously; awaiting first let the isLoading flip re-run
the effect and fire a duplicate request before the answer arrived.

`loadEpisodesForSeason` now reports whether the portal answered; existing
callers ignore the result and are unaffected.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:16:09 +02:00
4grayandClaude Fable 5 b94f40dc74 feat(i18n): add Hungarian translation (hu) (#1236)
Integrate the community-contributed Hungarian translation by
Tibor Hermann (@htibcsike) as the 19th locale:

- add apps/web/src/assets/i18n/hu.json (1,142 of 1,175 keys translated;
  32 keys added after the contribution fall back to English, plus the
  new LANGUAGES.HUNGARIAN endonym)
- register HUNGARIAN = 'hu' in the Language enum, SUPPORTED_LANGS,
  Angular date locale registration, and the TMDB language map (hu-HU)
- add LANGUAGES.HUNGARIAN = "Magyar" to en.json and all other locales
  via tools/i18n/fill-missing.mjs
- update README.md and CLAUDE.md language counts to 19

Closes #1192, refs #1140.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:20:16 +02:00
4grayandClaude Fable 5 bd07e17857 feat(m3u): DASH + ClearKey playback via Shaka Player (#1225)
* feat(m3u): extract ClearKey DRM from #KODIPROP playlist lines

Adds the typed ChannelDrm model (shared interfaces) and a KODIPROP
post-processing step in createPlaylistObject() — the single funnel for all
four playlist import paths. Parses inputstream.adaptive.license_type,
license_key and drm_legacy; ClearKey keys accepted as kid:key hex pairs,
W3C ClearKey license JSON, or a plain kid→key JSON map. Unsupported license
types (Widevine/PlayReady/license URLs) are preserved with supported=false
so playback can surface a DRM diagnostic instead of failing silently.
Also adds isDashStreamUrl/isDashChannel helpers for DASH routing.

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

* feat(playback): add Shaka DASH source engine with ClearKey support

Introduces ShakaVideoSession (libs/ui/playback/src/lib/shaka-engine/): a
lazily imported shaka-player engine (separate lazy chunk, ~217 KB transfer)
owning attach/configure/load with an operation queue and generation guard
against channel-switch races. Channel ClearKey config maps to
drm.clearKeys; channels with an unsupported license type emit a
DrmOrEncryption diagnostic without starting an engine. Shaka errors are
classified into the existing playback diagnostics
(PlaybackDiagnosticSource.Shaka).

Wires the engine into both built-in players like hls.js/mpegts.js:
- HTML5: extension === 'mpd' branch in playChannel(); hls/mpegts/native
  glue extracted to helpers to keep the component within the size budget
- ArtPlayer: customType 'mpd' in ArtPlayerSourceSession (+ getDrm seam)
- Shared controls: WebVideoControlsSource kind 'shaka' +
  WebVideoShakaControls using the Shaka 5 text model (selectTextTrack(null)
  hides subtitles; Player.setTextTrackVisibility no longer exists)

Adds a CJS shaka-player jest stub (video.js precedent) for web specs.

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

* feat(m3u): route DASH channels to the inline Shaka-capable player

DASH (.mpd) channels always play in a built-in web engine (radio
precedent): external MPV/VLC cannot receive KODIPROP ClearKey
configuration (VLC upstream #29465) and Video.js has no DASH bridge yet.

- shouldShowInlinePlayer() bypasses the external-player setting for DASH
- new shouldAutoLaunchExternalPlayer() guard consolidates the MPV/VLC
  auto-launch conditions in the m3u-state effects (incl. catch-up path)
- the M3U page overrides the player for DASH channels: ArtPlayer stays
  ArtPlayer, everything else falls back to the HTML5 player
- ChannelDrm is passed through ResolvedPortalPlayback into the synthetic
  player-view channel

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

* test(e2e): add offline DASH ClearKey fixtures and e2e coverage

Fixtures (apps/web-e2e/src/fixtures/dash/): ~4s VP9+Opus DASH, clear and
CENC-encrypted variants with fixed synthetic ClearKey credentials.
Content synthesized by ffmpeg; encryption done by Shaka Packager because
ffmpeg's mp4 muxer writes senc-only metadata (Chromium needs saiz/saio)
and cannot produce the subsample encryption the VP9 CENC binding
requires. Generation script + README document regeneration.

web-e2e (Chromium): import an M3U with KODIPROP ClearKey via raw text,
verify encrypted and clear DASH actually play (currentTime advances, no
diagnostic banner) and that an unsupported license type (Widevine)
surfaces the DRM diagnostic. Fixtures are served through Playwright route
interception with HTTP Range support; the Angular service worker is
blocked since SW-routed requests bypass interception.

electron-backend-e2e: the same happy path + negative against a local
Range-aware fixture server — the automated proof that ClearKey EME works
in the real Electron runtime (file:// secure context).

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

* docs: document DASH + ClearKey playback architecture

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

* fix(pwa): extract KODIPROP DRM on the web-backend /parse import path

The web-backend keeps its own playlist builder for the PWA URL-import
path, so the shared createPlaylistObject() DRM hook never ran there and
encrypted DASH channels imported by URL reached Shaka without keys.
Apply extractDrmFromRaw() in that builder too and cover the path with a
regression test.

Addresses Codex review on PR #1225.

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

* fix(playback): interrupt stalled Shaka loads and destroy failed engines

Two review findings on the ShakaVideoSession lifecycle:

- stop()/start() now tear the current player down immediately instead of
  queueing the destroy behind the in-flight operation. Shaka's destroy()
  interrupts a pending load() (LOAD_INTERRUPTED), so a stalled manifest
  fetch can no longer wedge the operation chain and block the next
  channel start (Codex P1).
- A rejected attach()/load() now destroys the failed player after
  emitting the diagnostic, so a non-functional engine never stays
  attached to the media element or exposed to the shared-controls
  bridge (Greptile P1).

Regression tests cover both paths. The Shaka fakes are consolidated into
a shared jest-free test double that mirrors the destroy-interrupts-load
semantic, and the ArtPlayer source-session spec is split (fixtures +
DASH cases) to stay within the max-lines lint budget.

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

* fix(m3u): unify DASH URL detection with playback extension normalization

isDashStreamUrl() used the simpler getStreamExtensionFromUrl(), so URLs
the player engines classify as DASH (stream.MPD, ?ext=mpd, ?format=mpd)
were not routed to the Shaka-capable inline player and lost their
ClearKey metadata with Video.js or external players configured
(Codex P2). The normalized getPlaybackMediaExtensionFromUrl() now lives
in @iptvnator/shared/m3u-utils (re-exported unchanged from the playback
lib) and both routing and engine selection share it.

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

* chore(lint): satisfy CI lint and CodeQL in DASH support files

- replace shell-built tar/npm commands with execFileSync arg arrays in
  the fixture generator (CodeQL: uncontrolled shell command)
- give jest stub methods explicit bodies (no-empty-function)
- compact the diagnostic label switches in WebPlayerViewComponent to
  stay under the max-lines budget

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

* fix(playback): tear down the Shaka engine on critical error events too

A non-recoverable Shaka error emitted after a successful load left the
dead engine attached to the media element and exposed to the
shared-controls bridge (Greptile P1, round 2). Critical error events now
destroy the player right after the diagnostic is emitted, matching the
load-failure path.

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

* fix(m3u): honor DASH catch-up URLs and drop unusable DRM fallbacks

Two Codex round-2 findings:

- The inline-playback DASH gate only examined the channel URL, while the
  external-player guard checks the resolved catch-up URL — a replay that
  resolves to an .mpd manifest with MPV/VLC configured ended up with no
  player at all. The gate now uses the effective playback URL
  (activePlaybackUrl ?? channel.url).
- The unsupported-DRM diagnostic advertised MPV/VLC fallback actions,
  but external players cannot receive the KODIPROP license config either
  — the diagnostic no longer recommends them.

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

* fix(playback): suppress unusable external fallback for ClearKey DRM failures

Runtime DRM errors on channels that carry KODIPROP ClearKey config (wrong
or rotated keys) advertised MPV/VLC fallback actions, but external
players never receive the license config — the fallback could only fail
differently. DRM-classified diagnostics from such channels no longer
recommend external players; clear channels keep the hint.

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

* fix(m3u): symmetric DASH inline gate and lazy DRM for pre-upgrade playlists

- The inline DASH gate is now true when either the channel or the
  resolved catch-up URL is DASH, mirroring the external-player guard —
  a .mpd channel whose catch-up resolves to .m3u8 no longer ends up
  with no player at all.
- Playlists imported before the DRM feature carry no drm field, but the
  raw KODIPROP block survived in the stored items; the M3U page now
  falls back to extractDrmFromRaw(channel.raw) at playback time, so
  encrypted channels work without a re-import (Channel gains raw?).

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

* docs: sync the DASH/Shaka contract across agent docs

Mirrors the DASH/Shaka source-engine contract into AGENTS.md and adds
Shaka to the shared web-video bridge descriptions in CLAUDE.md and the
player-controls contract; documents the lazy raw-KODIPROP DRM fallback
for pre-upgrade playlists in the M3U architecture doc.

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

* fix(playback): reset the media element for rejected DRM and widen ClearKey fallback suppression

- Switching from a playing stream to an unsupported-DRM DASH channel
  loads no new source, but play() still ran and the un-loaded element
  could resume the previous stream underneath the diagnostic banner.
  The HTML5 player now resets the element instead of playing.
- Any inline failure on a KODIPROP ClearKey channel (manifest, codec,
  media, network — not just DRM-category errors) is unsolvable in
  MPV/VLC, which never receive the license config; the external
  fallback hint is now suppressed for all diagnostics of such channels.

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

* fix(playback): restore suppressed DASH captions when the preference re-enables

The Shaka bridge dropped the auto-selected text track with
selectTextTrack(null) when showCaptions was off, but did not remember it
— re-enabling the preference mid-session left captions permanently off
(HLS/native bridges already restore). The session now remembers the
suppressed track id and reselects it via the bridge's caption-state pass;
suppression is also skipped when no track is active. Covered by session
and new WebVideoShakaControls specs.

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

* chore: retrigger CI

GitHub Actions created no check suites for the last three pushes to this
branch (third-party apps received the webhooks); an empty commit re-fires
the push and pull_request events.

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

* refactor(playback): split oversized Shaka session and HTML5 spec files

CI lint enforces max-lines 400: extract ShakaTextTrackSuppression and the
shaka-error helpers out of ShakaVideoSession, and move the DASH-specific
HTML5 player test into its own spec. No behavior change.

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

* ci: allow manual dispatch of the cross-platform E2E workflow

GitHub stopped delivering push/pull_request events for this branch;
workflow_dispatch provides a manual escape hatch (CI and build-and-make
already have one).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:31:18 +02:00
4grayandClaude Fable 5 6dc8a10d52 feat(playback): show media title overlay in fullscreen shared controls (#1228)
* feat(playback): show media title overlay in fullscreen shared controls

In fullscreen with the shared player-controls layer, a pointer-transparent
overlay at the top of the player now names the content while controls are
revealed: one line for movies and live channels, two lines for series
(series name + S01E03 label). The overlay follows the bar's auto-hide
transition and stays hidden outside fullscreen, where page chrome already
names the content.

Data flows top-down: the Xtream/Stalker series detail views pass the series
name via a new PortalInlinePlayerComponent.seriesTitle input (Xtream episode
playback titles carry the episode name, not the series), the inline player
builds the two-line form from the episode metadata label, and
WebPlayerViewComponent falls back to playback.title for movies/channels
while skipping raw stream-URL fallbacks. All four shared-controls hosts
(HTML5, Video.js, ArtPlayer, Embedded MPV frame-copy) forward the value.

To stay under the max-lines limit, scrub/timeline state is extracted into
ControlsTimeline and the playback-diagnostics display helpers into
web-player-view-diagnostics.utils.ts, with behavior unchanged.

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

* docs: mirror fullscreen media-title contract into AGENTS.md

Addresses Codex review: the Shared Player Controls section in AGENTS.md
must stay in sync with the CLAUDE.md description of the new mediaTitle
input, fullscreen overlay behavior, and series-title wiring.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:41:52 +02:00
4grayandClaude Fable 5 0273ded8e2 fix(tmdb): resolve season number from title markers for per-season series slices (#1229)
* fix(tmdb): resolve season number from title markers for per-season series slices

Providers often slice a show into per-season catalog items ("The
Mandalorian (2 season)", "Пацаны 2 сезон", "The Boys S05") and renumber
the single contained season to 1, so season enrichment fetched the wrong
TMDB season (season 1 metadata for a season 2 item).

- new season-marker.util.ts in shared/interfaces: extractSeasonFromTitle
  (word-first, number-first and S-form markers, bracketed or trailing)
  and resolveEnrichmentSeasonNumber (title marker wins only for
  single-season items whose provider number disagrees)
- wired into Xtream enrichSerialSeasonWithTmdb and the Stalker
  series-view season service (cache/overlay still keyed by provider
  season key)
- SEASON_SUFFIX_PATTERN now also strips number-first season suffixes
  ("2 season", "2 сезон", "2-й сезон" incl. NFD-decomposed ordinals) so
  such titles match the show at all

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

* fix(stalker): wait for the season map before TMDB season fetch

The TMDB match can arrive before the async season resource; fetching
then passed seasonCount 0, suppressed the title-marker override and
cached the wrong season forever (fetchSeason is idempotent). The effect
now reads the season map tracked and skips while it is empty —
overlay-driven re-runs are safe because fetchSeason early-returns per
(tmdbId, seasonKey).

Addresses Greptile review on #1229.

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

* fix(stalker): reset season selection on detail-to-detail navigation

The router reuses the series view for detail-to-detail navigation, and a
retained season selection let the NEW item's tmdb_id pair with the
PREVIOUS series' season context in the TMDB fetch effect, poisoning its
idempotent per-season cache before the new season resource loaded. The
selection is now a linkedSignal keyed on the displayed item's identity —
compared inside the computation, since displayItem produces a fresh
object on every recomputation.

Addresses Greptile review round 2 on #1229.

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

* fix(stalker): include the resolved season in the TMDB season cache identity

Per-season slices of one show share (tmdbId, provider season key "1")
but resolve to different TMDB seasons — plain key idempotency served the
first slice's episodes to every later slice. Each cache entry now
records the resolved season it was fetched for: a call resolving a
different season refetches and overwrites (also self-healing a fetch
made with stale navigation context), an in-flight marker dedups
concurrent runs, and a superseded fetch may not store its result.
Failed fetches stay uncached so later triggers retry.

Addresses Codex review (P1) and Greptile review round 3 on #1229.

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

* fix(stalker): drop a mismatched season cache entry before its replacement fetch

If a replacement fetch (same key, different resolved season) failed, the
previous slice's entry stayed visible indefinitely through overlay() and
descriptions(). The mismatched entry is now removed up front, so a
failed replacement falls back to provider data until a retry succeeds.

Addresses Codex review (P2) on #1229.

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

* fix(stalker): title-based season reset identity and o_name marker support

- The season-selection reset identity now combines provider id and title:
  distinct items can share or lack provider ids, and an id-only identity
  retained the previous item's selection across such navigation
- The season marker is read from whichever title field carries it via
  pickSeasonMarkedTitle: providers put the descriptive title in o_name
  while name stays generic, and the show-level match already used o_name

Addresses Greptile review round 4 (P1) and Codex review (P2) on #1229.

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

* fix(stalker): gate TMDB season fetch on resource coherence, not selection resets

Resetting the parent season selection on item identity change (previous
round) silently disabled enrichment after detail-to-detail navigation
between items sharing one season-key set: the season container keeps its
own selection and deduplicates seasonSelected emissions, so the parent
key stayed null forever. The reset is gone; instead the fetch effect
gates on coherence — it waits while the season resource reloads (the
window in which a reused component pairs the new item's tmdb_id with the
previous item's map) and requires the selected key to exist in the map
with episodes. Stale-snapshot fetches remain self-healing through the
resolution-aware cache.

Addresses Codex review (P2) and Greptile review round 5 on #1229.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:46:48 +02:00
4grayandClaude Opus 4.8 5aa44d19d4 feat(tmdb): clickable director/creator chips and directing credits on person pages (#1227)
* feat(tmdb): clickable director/creator chips and directing credits on person pages

Directors were plain merged text — no photos, no navigation — while the
data was already sitting in the cached TMDB payloads (credits.crew and
created_by both carry id + profile_path; they just were not typed or
parsed).

- tmdb-merge: enrichedDirectors (crew, job === 'Director', deduped by
  person id) and enrichedCreators (created_by) produce the same chip
  shape as the cast (TmdbEnrichedCastMember) into a new tmdb_directors
  field on all three merges (Xtream VOD, Xtream series, Stalker); types
  widened (crew id/profile_path, created_by id/profile_path).
- Detail views (shared VodDetailsComponent, Xtream vod/serial routes,
  Stalker series view) render the Director row as clickable avatar chips
  when tmdb_directors is present — same markup and openActor handler as
  the cast strip — falling back to the plain text otherwise. Stalker
  re-normalization allowlist preserves the new field.
- Person pages: mapPersonFilmography now merges combined_credits.crew
  (jobs Director/Creator) into the filmography — acting wins the
  per-title dedup, directing-only titles show the job in the character
  slot. Everything else (library matching, All-portals scope, filters,
  search fallback, back button) works unchanged because the person page
  is role-agnostic. Existing caches work as-is: crew/created_by were
  always part of the stored payloads.

Tests: merge spec (director/creator chips + crew-row dedup ×3 merges),
person spec (crew credits, Producer excluded, acting-wins dedup),
stalker-vod.utils passthrough. Docs updated (CLAUDE.md + architecture).

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

* fix(tmdb): address director-pages review — split oversized spec, stable track keys, translated crew roles

- tmdb-merge.spec.ts grew past the 400-line lint ceiling — the Stalker
  merge suite moved to tmdb-merge-stalker.spec.ts (fixes the CI Lint job).
- All cast/director chip loops now track by TMDB person id with an
  index fallback ('p<id>' / 'i<index>') instead of member.name — distinct
  people can share a name and creator payloads carry no dedup (greptile).
- Directing-only filmography credits carry the role in a new crewJob
  field ('Director' | 'Creator') instead of stuffing TMDB's raw English
  job into character; ActorViewComponent renders it through translated
  labels (XTREAM.CREW_JOB_DIRECTOR/CREATOR, added to all 18 locales via
  the i18n patch workflow, matching each locale's existing glossary —
  pt "Diretor", de "Regisseur") (Codex).

Tests: person spec asserts character/crewJob separation; merge suites
green after the split (15 + stalker file).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 08:42:41 +02:00
4grayandClaude Fable 5 59c15493a7 docs: sync CLAUDE.md, AGENTS.md and architecture docs with actual code
Full audit of CLAUDE.md, AGENTS.md, README.md and docs/architecture/
against the codebase; every fix is backed by current code:

- remove documented-but-unimplemented IPTVNATOR_DISABLE_HARDWARE_ACCELERATION
  flag (no reads anywhere in apps/, libs/, tools/)
- CLAUDE.md: add epg_channel_mappings to the schema table list
- m3u-playlist-module: *-tab dirs -> *-view (+recent-view), selectActivePlaylist,
  real PlaylistState shape, ChannelEpgMetadata instead of removed EnrichedChannel,
  actual /workspace/playlists routes, per-view outputs, live-epg-panel-state key
- workspace-dashboard: per-rail Settings.dashboardRails toggles, three missing
  rails in the diagram, split live-favorites/recent-live rails,
  welcome-dashboard empty-state type, RECENTLY_WATCHED_LIVE_TV title key
- stalker-portal: CategoryContentViewComponent for vod/series, collection-route
  components for favorites/recent, corrected series-view/favorites-button paths,
  actor/:personId route, epg panel selectors
- category-management: reloadCategories lives in with-content.feature.ts,
  workspace-context-panel owns the dialog, XtreamPendingRestoreService flow
- stalker-mock-server (+app README): scenario-seeded faker, resetAll() clears
  content cache too, ordinal season episode ids, handlers/ dir location
- sqlite-db-worker: cancellation shipped (drop from out-of-scope), full
  operations module list
- portal-detail-navigation: replace three removed component paths
- tmdb-metadata-enrichment: details cache keys are id:<tmdbId>|v2
- electron-security: CSP frame-src youtube-nocookie exception,
  sandbox: !frameCopyExperiment nuance
- download-manager: libs/portal/xtream instead of xtream-electron folder,
  data-driven downloads nav, drop removed app-search-result-item note
- playlist-backup-restore: settings-backup facade owns the import handoff
- workspace-shell: functional workspaceEntryRedirect, playlists route children
- iptvnator-ui-guidelines: EPG card radius 11px, detail-view mixin is `base`
- embedded-mpv-native, player-controls-contract: minor precision fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:16:33 +02:00
4grayandClaude Opus 4.8 db70b07093 feat(playback): theater stage and opt-in ambient fill for the inline portal player (#1223)
* fix(tmdb): purge obsolete search cache rows

* feat(playback): theater stage and opt-in ambient fill for the inline portal player

On wide-short windows the VOD/series inline player left a strip of app
surface next to the video: with `width: auto`, the viewport's `max-height`
transferred through `aspect-ratio` into a max-width (CSS transferred size
constraints), re-clamping the stage to 16:9 and leaving the leftover
outside it.

- Theater stage: give `.player-shell__viewport` a definite `width: 100%`
  so it always fills the content row; the player renders as the largest
  16:9 box that fits the stage height, centered — the leftover is always
  the stage's black background, never app surface (YouTube-style
  letterbox). Applies to every inline engine.
- Ambient fill: new `playerAmbientMode` setting (default off, Settings >
  Playback, web players only) renders a blurred, dimmed copy of the
  poster behind the player, filling the letterbox margins. Enforced at
  runtime too: Embedded MPV never gets the extra DOM layer. Live channels
  and non-http(s) poster URLs are excluded.

Verified live via CDP at 1720x760 (stage 1362x532, player 946x532 with
symmetric 208px margins) and 1280x950 (stage exactly 16:9, no bars).

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

* fix(i18n): add ambient-mode setting keys to all remaining locales

The i18n drift gate requires SETTINGS.PLAYER_AMBIENT_MODE and its
description in every locale; the feature commit only covered en and ru.
Translated via the i18n-fill workflow (per-locale patch + mechanical
merge, glossary-matched against each existing file).

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

* test(settings): include playerAmbientMode in expected default settings

settings.component.spec asserts the persisted settings object with
toEqual; the new default-off field has to be part of the fixture.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 08:02:11 +02:00
4gray d308749e2c fix(stalker): preserve is_series episode metadata (#1218) 2026-07-21 07:51:37 +02:00
4grayandClaude Fable 5 c707af1334 feat(epg): manual EPG mapping for Stalker portals (#1173)
* feat(epg): manual EPG mapping for Stalker portals

Extends the manual EPG-to-channel mapping (PR #1165) to Stalker:

- shared key helper buildStalkerEpgMappingKey — playlist-scoped
  stalker:{playlistId}:{channelId} keys, mirroring the Xtream scheme
- ITV sidebar: right-click context menu with "Map EPG channel"; after
  the dialog closes with a change, the bulk EPG cache is rebuilt so the
  panel and row previews reflect the new mapping immediately
- withStalkerEpg().applyMappedItvEpg(): batch-resolves mappings for
  rendered channels (one getEpgMappingsBatch IPC per new id set) and
  overlays uploaded-XMLTV programs onto bulkItvEpgByChannel; overrides
  survive ensureBulkItvEpg reloads
- stream-resolver: mapping check in loadStalkerEpgItems (detail) and
  batched prefetch in loadStalkerEpgBatch (previews);
  mappingCandidateKeys/prefetchEpgMappings generalized beyond Xtream
- global favorites: stalker branch for the Map-EPG menu entry (item id
  extracted from the stalker::{playlistId}::{id} uid)
- docs: manual-mapping section in docs/architecture/stalker-epg.md and
  a CLAUDE.md EPG bullet covering the whole mapping feature
- tests: applyMappedItvEpg suite, stalker preview mapping in the
  stream-resolver spec, key-builder specs

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

* fix(epg): harden Stalker mapping edges found in adversarial review

- stream-resolver: stalker items resolve only the playlist-scoped
  mapping key — bare tvgId/name candidates (tvgId mirrors the raw
  provider id) could only produce false matches against unrelated M3U
  mappings, and previews would disagree with the detail path
- applyMappedItvEpg: staleness guard after every await so an in-flight
  call cannot write portal A's mapped EPG into portal B's state after
  a playlist switch (the store is a root singleton)
- applyMappedItvEpg: ids are marked checked only after a successful
  lookup — a transient IPC failure no longer suppresses the mapping
  for the rest of the session
- live layout: post-dialog refresh re-applies overrides for the
  unfiltered channel list, so an active search cannot drop the playing
  channel's mapping
- global favorites: new epgMappingChanged output emitted when the
  dialog actually changed a mapping; unified live tab reloads its EPG
  previews in response

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:29:23 +02:00
4grayandClaude Fable 5 ec09778f36 feat(about): show build commit next to the app version (#1208)
* feat(about): show build commit next to the app version

Settings > About now renders "<version> (<short-sha>)" with the full
SHA in the tooltip, so bug reports from test and nightly builds
identify the exact commit. The commit is injected at CI build time into
apps/web/src/environments/build-commit.ts (same placeholder pattern as
the TMDB key inject); PR builds use the real head SHA instead of the
ephemeral merge commit. Local/dev builds keep the plain version.

The semver version itself deliberately stays untouched: a "-sha"
suffix would flip electron-updater into prerelease mode and leak into
installer/artifact version fields.

Requested by WolfganP in #1202.

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

* style(settings): keep relative import after monorepo alias imports

Addresses Greptile feedback on #1208.

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

* feat(docker): inject build commit into published PWA images

The Docker/PWA build path bypassed the Electron workflow's inject step,
so published images showed the plain version in About. Pass the commit
as a build arg and run the inject script before the PWA build; the
script no-ops when BUILD_COMMIT is empty, leaving local docker builds
unchanged.

Addresses Codex feedback on #1208.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 09:44:08 +02:00
4gray 5cae310430 fix(logging): redact sensitive portal and Electron diagnostics (#1182)
* fix: redact sensitive log data

* fix(ci): keep logging preload self-contained

* fix(logging): preserve shared diagnostics

* fix(logging): close trace redaction gaps

* fix(logging): redact Xtream path credentials

* fix(logging): close credential redaction gaps

* fix(logging): suppress external player arguments

* fix(logging): harden URL and date redaction

* fix(logging): redact map keys and URL fragments

* fix(logging): redact sensitive map values

* fix(logging): redact credentials in diagnostic text

* fix(logging): close remaining credential leaks
2026-07-19 08:30:21 +02:00