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>
settings.component.spec.ts was 1516 lines and the last settings file in the
max-lines baseline. The behaviour that moved into facades now has its own
specs, driven directly instead of through the rendered page.
- settings-app-update.facade.spec.ts: status polling/retry, bridge actions,
release notes dialog, version messaging, dispose
- settings-epg.facade.spec.ts: refresh, clear flow, post-save re-fetch
- settings-playlist-reset.facade.spec.ts: summary, dialog, Electron progress,
browser fallback, failure snackbar
- settings-backup.facade.spec.ts: desktop export, browser download fallback
- settings.component.spec.ts keeps the page shell, the facade lifecycle seam
and runtime capabilities; settings.component.form.spec.ts takes hydration,
section outputs, dashboard controls and submit
- settings-section-scroll.directive.spec.ts gives the directive its first spec
- shared TestBed fixtures live in settings/test-stubs/, kept out of both the
app build (.stub.ts) and the coverage ratchet (test-stubs/)
105 settings tests, up from 94; every file is under the 400-line limit, so
settings.component.spec.ts leaves the baseline.
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.
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.
settings.component.ts had grown to 819 lines — past the CLAUDE.md target (<300)
and hard maximum, passing lint only because it sat in the max-lines baseline.
The behaviour moves into facades the template binds to directly, following the
precedent already in this folder: new app-update (218), form (197), epg (123),
embedded-mpv (74) and remote-control (37) facades, with playlist-reset extended
to 143 and settings-options to 200. The component is now a 259-line coordinator
holding capability flags, section nav, players() and the cross-facade flows.
settings.component.ts is removed from the max-lines baseline.
No behaviour change. One ordering detail: applyChangedSettings now applies
language/theme before kicking off the EPG re-fetch; changeTheme only touches DOM
theme sync and translate.use does not touch the form, so the two are
independent.
Adds contract-focused regression coverage for the Electron HTTP server,
remote-control events, settings events, and managed download paths, and makes
Tier A coverage fail closed when instrumentation fails or a runtime-owning
production file disappears from a project or from the merged Istanbul report.
The old `coverage:ci` exited 0 despite a `Failed to collect coverage`
diagnostic: libs/m3u-state/src/lib/effects.ts was simply absent from the merged
map. All 30 Tier A reports are now required, the merged map covers 710 files,
and effects.ts is reported as 0/159 instead of silently disappearing.
Also fixes remote static-file path containment for encoded, malformed, NUL,
POSIX and Win32-style traversal inputs, with behavior-preserving testability
seams.
Statements 69.27% -> 69.54%; http-server.ts 0% -> 90.21%,
remote-control.events.ts 0% -> 96.55%, settings.events.ts 59.25% -> 96.29%.
Supersedes #1252 and #872. uuid 14 is ESM-only, apps/web/jest.config.ts only
kept v9 working by mapping `^uuid$` at a `wrapper.mjs` that v14 no longer
ships, and the specifier also has to be synced in
libs/shared/m3u-utils/package.json or @nx/dependency-checks fails lint.
All four call sites only used `v4()`, so the dependency goes away instead.
`createRandomId()` prefers `crypto.randomUUID()` and falls back to building the
same v4 shape from `crypto.getRandomValues()` — that fallback is load-bearing,
because randomUUID is only exposed in secure contexts and the self-hosted PWA
is regularly served over plain http on a LAN address. getRandomValues stays
available there, and it is what uuid's own v4 used.
`@types/uuid` goes too; it only existed for the untyped v9 package.
Rebuilt from #1251 so the group could merge, on top of the transitive-CVE
overrides from #1258. Supersedes #1230 and #1251.
Carries axios 1.16.0 -> 1.18.1, closing seven runtime-scope advisories
including the proxy-credential leak on redirects, and sharp 0.34.5 -> 0.35.3
for the libvips CVEs.
`esModuleInterop` moves to tsconfig.base.json. artplayer 5.4.0 switched from a
Parcel build exposing `module.exports.default` to UMD assigning
`module.exports` directly; the flag was only set in apps/web, so every lib
compiled `import Artplayer from 'artplayer'` to `.default` and got undefined.
Production was never affected — esbuild resolves the ESM entry.
Two packages are deliberately held back, each for its own PR:
- epg-parser ^0.5.0 — grouped as a minor, but 0.x minors are breaking and this
one reshapes the parse output (`channel.name` -> `displayName`, icons/urls
become objects, `credits` becomes role-keyed, dates switch to ISO). Its only
consumer is the uncovered web-backend `/parse-xml` endpoint.
- electron-builder ^26.15.3 — rewrote the snap target, and the resulting snap
cannot start (`command.sh` execs a `desktop-init.sh` that never lands at the
snap root under our core22 strict config). Its two required fixes go with it:
the `engines` node floor from @electron/rebuild 4, and resolving upstream
node-gyp instead of the dropped `@electron/node-gyp` fork.
The custom minimize/maximize/close controls stayed hidden forever after
leaving HTML-element (video player) fullscreen on Windows: window state was
polled at event time, and isFullScreen() can still report the pre-transition
value while 'leave-full-screen' fires, leaving a stale push with no later
event to correct it. The same polling on the companion flag cleared
isMaximized during fullscreen transitions and stuck the maximize/restore
glyph on the wrong icon.
attachWindowStateEvents now seeds the state once at window creation and each
event patches only the flag it names, sending a copy per push. The
enter/leave-html-full-screen variants are wired too.
Regression coverage: app-window-state.spec.ts (9 cases, 6 of which fail
against the old implementation) and an Electron E2E case that toggles HTML
element fullscreen and asserts the controls come back.
* feat(tmdb): metadata cache panel with a clear button in settings
Adds "Metadata cache — N entries · X MB" with a Clear button to
Settings > Metadata (TMDB), next to the API key it belongs to.
Three things it is good for: dropping stale or wrong metadata so the next
open refetches it, seeing what the cache actually costs on disk, and
reclaiming rows that a lookup-key version bump has orphaned — a bump makes
rows unreachable, not deleted, so nothing else would ever collect them.
Sizing the cache is a full table scan (LENGTH() on TEXT counts characters,
so the SUM casts to BLOB to get bytes), which is why stats load lazily and
only once the TMDB section is the active one rather than on every settings
open. Clearing is always safe: enrichment refetches on demand, so the only
cost is the next few requests.
Works in both environments — the PWA has no bridge, so the service reports
and clears its session-scoped in-memory map instead.
i18n: 4 keys across all 19 locales via the tools/i18n workflow;
placeholder integrity verified. Contract fixtures updated for both the
preload bridge and the DB-worker payload shapes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): make cache clearing durable and stop reporting failures as empty
Four review findings, all real:
- A metadata write already in flight when the user cleared would land
afterwards and silently restore what they removed. Writes now carry the
generation they started in; a write that outlives a clear is dropped
(PWA) or undone (Electron).
- The PWA byte count used String.length, i.e. UTF-16 code units, so
localized payloads under-reported and disagreed with the SQLite BLOB
byte count. TextEncoder now measures actual bytes.
- A failed stats read returned a valid zero-entry result, so the panel
claimed an empty cache and disabled Clear while rows were still there.
getStats/clear now return null on failure and the panel says so instead
of inventing state.
- No behavioural coverage existed for either side.
Tests: SQL ops (entry/byte reporting, empty table, missing row, delete
count) and the service (encoded bytes, clear count, and a write racing a
clear).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): make the cache clear precise and version skew visible
Review follow-ups on the cache panel:
- A write that was in flight when the user cleared used to trigger a
second full-table clear once it landed, which also deleted anything
written in between. clear() now waits for the writes issued before it
and lets the single clear take them; later writes survive.
- An Electron shell without the maintenance ops fell through to the
renderer map, which is always empty there — it reported an empty cache
and disabled the Clear button while SQLite was full. Both operations
now report unsupported instead.
- Component coverage for the panel (deferred scan, clear + re-read,
failed clear, failed read) and Electron-path service coverage.
- The canonical IPC and settings sections of the enrichment doc, plus
the matching CLAUDE.md lines, now list the maintenance ops.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): drop Promise.allSettled from the cache clear
The web target compiles against lib es2018, so allSettled broke the
Windows frontend build (TS2550). The pending writes swallow their own
errors, so a plain Promise.all over neutralized promises does the job.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): keep a synchronous bridge throw inside the cache write
Moving the write into a tracked promise dropped the try/catch that used
to cover the call itself, so a bridge that threw synchronously would
escape set(). Wrap it in an async IIFE, which turns that back into a
rejection the same handler swallows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): retry the cache size read when the section is reopened
The effect skipped the read once cacheError was set, so one transient
IPC failure left the panel showing "could not read the cache" for the
life of the settings page — and the only enabled control that could
shift it was the destructive Clear button. Gate on the stats signal
alone: reopening the section retries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): queue writes that start while the cache is being cleared
Awaiting the in-flight writes closed one side of the race and left the
other open: a set() that started during that wait dispatched its IPC
immediately, was absent from the snapshot, and could reach SQLite just
before the delete — so a row written after the user clicked Clear was
removed anyway.
clear() now holds its own promise for the whole operation and set() waits
on it, which puts such a write on the far side of the delete. Rows are
stamped when they are dispatched rather than when set() was called, since
a write may have waited. Covered by a test that fails without the guard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(tmdb): add the release note for the cache panel
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(tmdb): cover the cache panel with an Electron E2E
The panel drives IPC and SQLite, and nothing exercised that path end to
end. The new test seeds a row through the preload bridge — enrichment
itself needs a TMDB key that CI does not have — then opens the section,
asserts the reported size, clears, and reads the database back to confirm
the row is gone rather than merely hidden.
Verified both ways: dropping the DELETE from clearTmdbMetadata fails it.
Settings nav buttons gained a data-test-id so the section can be opened
without matching translated labels.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Favorites and recently-viewed rows store Stalker items as full JSON
snapshots, so a vclub-style embedded series[] episode list froze at the
moment the row was written: a series favorited when only episode 1 was out
kept showing one episode forever when opened from favorites, recents,
Continue Watching, or any dashboard rail.
New withStalkerSnapshotRefresh() store feature renders the stored snapshot
immediately and re-fetches the item from the portal in the background via a
title search (get_ordered_list&type=vod&search=..., matched by id, paginated
up to 5 pages, wildcard-category retry), patching fresh episodes and cmd into
the active selection. The patch is guarded on both the item id and the active
playlist id, since Stalker ids are only unique per portal.
Only the in-memory selection is patched — the stored snapshot row is
deliberately left alone, because every entry path into the detail view runs
this refresh and writing it back would add an uncontrolled background writer
to the whole-playlist read-modify-write that every favorite/recent mutation
performs.
Also fixes the stalker-mock-server embedded-series scenario, which generated
series[] as objects the app's vclub adapters filter out instead of the
episode-number arrays real portals send.
Regular type=series and Ministra is_series items are unaffected; Xtream is
unaffected (get_series_info is never cached).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Two independent CI failures on master:
1. `Check i18n drift` failed with 3 keys missing from `hu.json`
(`SETTINGS.PLAYER_UP_NEXT_RAIL`, `SETTINGS.PLAYER_UP_NEXT_RAIL_DESCRIPTION`,
`PORTALS.UP_NEXT`). PR #1231 added them to every locale, but its branch
predates the Hungarian locale merged in #1236, so `hu.json` never got them.
Both PRs were green in isolation. The failure also aborted the job before
the Tier A/B/C unit suites ran. Added the keys with real Hungarian
translations rather than English fallbacks.
2. CodeQL has failed on every master push for days. The analysis itself
completes; only the SARIF upload fails with "Resource not accessible by
integration" because the workflow has no `permissions:` block and the
default token is read-only. Added the standard grant.
While in that workflow: bumped `actions/checkout` v3 -> v4 (matches every other
workflow here) and dropped the obsolete `git checkout HEAD^2` step — the old
template's PR-head trick that current codeql-action handles itself, and the
only reason `fetch-depth: 2` was needed.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* 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>
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>
* feat(m3u): support #KODIPROP lines placed before #EXTINF
Bumps the iptv-playlist-parser fork pin to v0.15.2-iptvnator.2: Kodi
property lines apply to the next list entry, so #KODIPROP lines placed
above the #EXTINF are now preserved in item.raw (previously the parser
dropped them and ClearKey config in that layout was lost). The DASH +
ClearKey feature (#1225) extracts license config from item.raw, so both
KODIPROP layouts now work on every import path.
Covered by a parser contract case and an extended web-backend /parse
regression (before-EXTINF + between-EXTINF-and-URL + plain channel).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: reflect before-#EXTINF KODIPROP support in the M3U architecture doc
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: list the KODIPROP delta in the parser-fork inventory
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The startup auto-refresh always opened the
`HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SUCCESS` snackbar, even when the backend
had dropped playlists it could not refresh. Isolating per-playlist failures
(#1233) means the result set is lossy by design, so an unreachable source that
now fails within `PLAYLIST_FETCH_TIMEOUT_MS` produces a false success toast.
`autoUpdatePlaylists()` now returns `AutoUpdatePlaylistsResult` — the refreshed
playlists plus one outcome per requested playlist (`updated` / `failed` /
`skipped`), in request order — on top of the existing bounded-concurrency
refresh. The renderer derives the message from those outcomes:
- all updated -> `AUTO_REFRESH_UPDATE_SUCCESS` (unchanged)
- some failed -> `AUTO_REFRESH_UPDATE_PARTIAL` (error styling, dismissable)
- some failed and some skipped -> `AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED`
- none updated -> `AUTO_REFRESH_UPDATE_FAILED` (error styling, dismissable)
- only sourceless playlists left over -> `AUTO_REFRESH_UPDATE_SKIPPED`
Playlists with neither a URL nor a file path are reported as skipped rather
than failed, since there is no source to refresh them from. The mixed
failed+skipped message exists because the plain partial text names only
updated/total/failed, which would leave the skipped playlists as an
unexplained remainder. Titles of unresolved playlists are logged for
diagnosability.
Tests: five new `electron.service` cases (one per message branch), outcome
assertions across the existing `playlist-auto-update` and `playlist.events`
specs, and an Electron E2E that restarts the app against a killed playlist
server — verified to fail against the old unconditional toast.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Playlist downloads in the main process ran without an axios timeout, so a
host that accepted the connection and then went silent kept the request
pending forever. The startup auto-update refreshed playlists sequentially,
so that one unresponsive source also withheld every playlist that did
refresh, and the URL import dialog could spin with no way out.
- Add PLAYLIST_FETCH_TIMEOUT_MS (30s) to the main-process fetch and reuse it
in the refresh worker instead of its duplicated literal. Redirects are
followed one hop at a time, so each hop is bounded separately.
- Move auto-update into playlist-auto-update.ts and refresh at most
AUTO_UPDATE_CONCURRENCY (3) playlists at once, isolating each failure while
preserving the requested order. An unbounded fan-out would download and
parse arbitrarily many large M3U files in the main process at once.
- Redact refresh log URLs, which routinely carry Xtream username/password
query parameters.
The existing auto-update spec handed out fixtures by call order, which no
longer holds once refreshes overlap; it now keys them by source type.
Fixes#931
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Category rows crossed the DB-worker IPC boundary with Drizzle's camelCase
property names while the renderer contracts declare snake_case, so backup
export dropped hidden-category IDs and restore degraded to a type-only
match that hid every category. Project category ops to the declared wire
shape, normalize restore state from untrusted sources (dropping entries
without a numeric xtreamId), reject entries with missing user-state
collections, and add full export→import round-trip coverage (unit
manifest-equality + Electron e2e) plus regression tests.
Closes#1017
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 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>
Adds a paused state to the Electron download manager with a full partial-file lifecycle:
- Pause keeps the .part and byte progress; cancel discards them; every lifecycle stage (queued, active, mid-transfer) is pausable.
- Resume continues via HTTP Range with If-Range entity validation (strong ETag / Last-Modified persisted in the new resume_validator column, idempotent migration incl. legacy-table rebuild). Non-206 answers restart from zero over the same .part; the 206 Content-Range offset is verified; responses that end before the advertised size are retained for a Range retry instead of being committed as completed.
- Crash recovery converts interrupted transfers to paused, keeps queued-with-partial rows resumable, and commits finalizations that crashed before the DB update.
- Destination collisions are non-destructive (retained partials finalize to the next numbered name); locked .part files never lose their DB owner across cancel/remove/restart; resume claims rows atomically and the queue dedupes ids.
- Stored request headers are re-filtered through the User-Agent/Origin/Referer allowlist on read, URL-derived extensions are sanitized, resume appends never follow symlinks, and transfer errors are logged by message only.
- UI: pause/resume/cancel/retry/remove surface failures in a snackbar; paused items show an active Resume button in VOD/episode detail views; translations for all 18 locales.
- Runtime split into download-runtime/transfer/finalize/broadcast modules; +30 unit tests and an Electron E2E covering pause -> retained .part -> Range/If-Range resume -> byte-exact assembly.
Co-authored-by: genrichh93-ui <genrichh93@users.noreply.github.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Search normalization splits "A&E" into the 1-letter tokens "a" + "e", and
short first tokens are prefix-anchored for performance (GLOB 'a*' plus a
startsWith gate in the ranking), so provider-prefixed channels such as
"US: A&E" could never match.
Words that keep internal punctuation after edge-trimming ("a&e", "x-men",
"l'equipe") are now preserved as compound words and matched as an intact
substring in addition to the existing token logic:
- global Xtream search supplements the unchanged prefix/GLOB arm with a
trigram FTS substring lookup (MATCH '"a&e"'), deduped by content id,
falling back to the content scan if FTS is unavailable
- the ranking gate lets multi-token phrases match as a space-bounded whole-
word sequence anywhere in the title ("US: A&E", score 40) without crossing
word boundaries ("Casa e Villa" stays excluded)
- per-playlist search and the M3U payload prefilter OR-in intact-word
contains patterns
SQL conditions compose per word so a compound word's substring arm stays
AND-constrained by the other words of the query ("A&E HD" cannot flood the
bounded candidate window with plain "A&E" titles); the FTS supplement AND-s
the non-compound tokens as LIKE conditions.
Pure search-text helpers moved into content-search.util.ts.
Fixes#1161
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 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>
* 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>
Load the complete ITV channel list once per portal (Ministra get_all_channels with a paged crawl fallback) and use it for: full local search across every channel, per-genre category count badges, an all-channels paginated grid on Live TV entry, and eager bulk EPG so row previews appear without playing a channel. Censored (adult) genres absent from get_all_channels fall back to the legacy paged flow and show no badge. Includes mock-server support, unit + E2E coverage, and architecture docs.
* fix(epg): harden three latent edges from the #1165 manual-mapping review
Follow-up to #1165 (manual EPG-to-channel mapping). Three minor but real
issues flagged by the bot reviews on #1173, all in already-merged #1165
code rather than the Stalker delta:
1. EPG program dedup ignored source_url. The unique index and upsert key
(channel_id, start, title) collapsed programmes imported from different
XMLTV sources that shared those columns, and the upsert reassigned
source_url to the last importer — so source-scoped queries could miss a
programme and source-scoped deletes could drop another source's row.
The key and index now include source_url (migrated via a _v2 index that
drops the old source-blind one); the upsert no longer overwrites
source_url.
2. Xtream getMapping fallback capped candidate streams at an unordered
first five, so a mapping saved under a later stream sharing the
provider epg_channel_id was silently ignored. Replaced the two-step
fetch-then-lookup with a single content⋈categories⋈mappings join that
finds a mapping under any matching stream, with no arbitrary cap.
3. The Xtream mapping dialog did not refresh after closing, unlike the
Stalker path, so a remapped visible/selected channel kept its stale
preview until the 5-minute TTL or a rescroll. Added
EpgQueueService.invalidate(streamId) and a before/after mapping compare
in the channel-list dialog flow that invalidates the cache and refetches
the current viewport when the mapping actually changed.
Tests: source-aware dedup index/upsert assertions; join-based getMapping
resolution regardless of stream position; EpgQueueService.invalidate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(epg): address review feedback on the #1165 follow-up
- portal-channels-list: forward the already-validated playlistId into the
mapping dialog instead of re-reading currentPlaylist() after the async
getEpgMapping roundtrip (which could return undefined on navigation)
- EpgQueueService.invalidate(): bump a per-stream invalidation epoch and
clear inFlight so a request already running when the mapping changes has
its (pre-change) result discarded via an epoch check in fetchEpg, and the
immediate re-enqueue can schedule a fresh mapping-aware fetch
- getMapping Xtream fallback: order the join deterministically before
limit(1) so the resolved mapping is stable; documented that this backend
layer has no caller playlist context and is a best-effort net behind the
renderer's playlist-scoped resolution
Tests: in-flight staleness discard for invalidate().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(epg): split EpgQueueService invalidation specs under the max-lines limit
The added invalidate() tests pushed epg-queue.service.spec.ts to 415 lines,
over the 400-line ESLint cap (and the baseline must not grow). Moved them to
a focused epg-queue-invalidation.spec.ts; both files are now under the limit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(epg): resolve second-order review findings on the mapping follow-up
Two P2 issues Codex raised on the previous fixes:
- Unscoped program lookups could return the same programme twice now that
the dedup index preserves per-source rows: the M3U timeline calls
getChannelPrograms without sourceUrls, so two sources sharing
channel/start/title both surfaced. Added toEpgProgams(), which collapses
duplicate channel|start|title slots after mapping/validation, applied at
every getChannelPrograms return.
- EpgQueueService.fetchEpg unconditionally cleared the in-flight marker in
its finally, which could drop a marker a re-enqueued request took over
after invalidate(). It now only releases the marker when the completing
request still owns it (epoch unchanged), preserving per-stream dedup and
concurrency accounting.
Tests: unscoped duplicate-slot collapse; stale request preserving a fresh
in-flight marker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(epg): deduplicate program rows in SQL before applying row limits
Codex follow-up: the JS-level slot dedup ran after the SQL LIMIT, so
duplicate cross-source rows consumed the cap and truncated real data.
Moved the dedup into SQL with GROUP BY, applied before the limits:
- selectChannelPrograms / selectLegacyChannelPrograms: GROUP BY
(channel_id, start, title) before ORDER BY start LIMIT 500, so the
timeline cap counts distinct programmes rather than duplicate rows
- selectCurrentProgramsForChannelIds: GROUP BY channel_id before
LIMIT channelIds.length, so duplicate cross-source current slots can't
starve other channels of their current-programme preview
The JS toEpgPrograms() dedup stays as a safety net (e.g. legacy NULL-source
rows the unique index treats as distinct). Test query-chain mocks updated
for the new groupBy link.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>