From 8f861a3a1b2786afb701e7e17c83a43f6ab390b2 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:23:20 +0200 Subject: [PATCH 01/18] fix(ui): make the workspace usable on phone-sized screens (#1326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): make the workspace usable on phone-sized screens The shell was half-adapted below 640px: the rail flipped to a horizontal bar but the link lists inside it kept stacking downwards, so the navigation was drawn outside the bar and over the header (#1100). Three resizable rails — the shell context panel, the live-layout channel sidebar and the M3U channel drawer — kept their persisted desktop width, which left the content around 50px on a 375px screen. They now span the full width and stack above the content. The inline width written by ResizableDirective is why these rules need `!important`. Found while walking the rest of the UI at 375px and 768px: - The detail hero kept poster and details side by side, squeezing the action row below its own labels until "Play" was clipped to its icon. - The settings section list did not scroll and painted over the footer, which also affected short desktop windows. - Hiding the M3U channel list on a phone was one-way: the restore handle was hidden and only Cmd/Ctrl+B could bring it back. - The live header drew the channel count and the paginator on top of each other up to tablet width, because the paginator does not shrink and the meta collapsed to zero width and overflowed its box. - The search scope checkbox was pushed off the right edge. Live TV states a floor for the player instead of a ceiling for the lists, so the video keeps a usable share of the screen under the categories panel and the channel list. Closes #1100 Co-Authored-By: Claude Fable 5 * fix(ui): address review — keep the palette reachable and the video visible Two findings from the Codex review on #1326. Hiding the command-palette trigger on phones removed the only pointer-driven way to open it: the rail renders route links plus Settings and emits nothing, so `commandPaletteRequested` had exactly one source. The button stays and its keyboard-shortcut label is swapped for an icon instead. Doing that exposed a latent flex trap in the same row — an keeps an intrinsic min-width from its `size`, and `min-width: auto` honours it, so the field refused to shrink and pushed the trigger out onto the buttons beside it. The M3U drawer released the shared player floor, which on a short landscape phone (600-640px wide) left the content container at half the shell body. The inline guide inside it is `flex: 0 0 ` and took its full 180px out of a container that no longer had it, so the video could reach zero height. The floor is restored and now yields on short viewports, the video states its own minimum, and the guide is what gives way. Co-Authored-By: Claude Fable 5 * fix(ui): let the channel list keep its height on a landscape phone Follow-up to the review: the player floor added in the previous commit was measured against the viewport, not against what the shell had left. On a 640x360 landscape phone the stacked categories panel already takes 30vh, so claiming another 50vh here drove the channel sidebar to zero height while it was still marked expanded — no way to pick another channel — and pushed the layout past the viewport. The floor now applies only where the screen can afford it (`min-height: 600px`), the sidebar states a floor of its own so it cannot be squeezed out, and the collapsed rule clears that floor so hiding the list still works. Below that height the two panes simply share what is left. Portrait is unchanged: categories 244px, channel list 220px, player 240px on a 375x812 screen. Co-Authored-By: Claude Fable 5 * fix(ui): address review — settings nav on landscape, poster dead space Two more findings from the Codex review. The stacked settings context panel capped itself at 30vh, which on a 360px screen is 108px — less than the panel's own title and footer, so the seven section rows collapsed to nothing behind an overlapping footer. On short screens the caption gives way (the rail already labels the page), the footer sheds its tall-screen padding, and the settings variant gets a slightly larger cap: unlike the live routes there is no player below competing for height, only a scrollable form. The poster kept a 330px minimum from the skeleton fallback at the bottom of the file — sized for the 220px desktop poster — while the stacked phone hero renders it 140px wide with a ~210px aspect-ratio height. Every loaded detail page carried ~120px of empty space between the poster and the title. The override sits after that rule because it wins on source order, not specificity. Co-Authored-By: Claude Fable 5 * fix(ui): let the playlist switcher yield to the search field on narrow phones Codex review of d6133da9: on a 320px header a route that contributes its shortcut button left the search field less than its own chrome needs (~74px of icon, palette trigger, gaps and padding), so the field's contents spilled onto the buttons beside it. The switcher is the one header region whose content can ellipsize, so it is what shrinks — down to an 88px floor — while the field states its chrome as a minimum. The field's basis moves from auto to zero so the input's intrinsic size stops counting as content: with basis auto the field claimed its intrinsic width even when room was ample and squeezed the switcher to ~115px on a 375px screen that could fit all 140. Co-Authored-By: Claude Fable 5 * fix(ui): fit the switcher's own chrome inside its phone floor Follow-up to the Codex note that the trigger's fixed chrome (type icon, refresh, chevron, gaps, padding) exceeds the 88px floor the shell now allows the switcher to shrink to. The flagged scenario itself cannot occur — the Multi-EPG shortcut needs Electron bridge methods the PWA lacks, and Electron enforces a 900px minimum window width so it never sees the phone breakpoint — but the floor should hold on its own terms rather than by accident of which buttons happen to render. Dropping the decorative type icon on phones brings the fixed chrome under the floor, and the name gets the space instead. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .changes/ui-mobile-layout.md | 10 +++ .../src/app/settings/settings.component.scss | 3 + docs/architecture/iptvnator-ui-guidelines.md | 52 ++++++++++++++- .../video-player/video-player.component.scss | 18 ++++-- .../playlist-switcher.component.scss | 19 ++++++ .../category-content-view.component.scss | 7 +++ .../search-layout.component.scss | 20 ++++++ .../stalker-itv-all-items.component.scss | 30 ++++++++- .../live-stream-layout.component.scss | 34 +++++++++- .../content-hero/content-hero.component.scss | 31 +++++++++ libs/ui/styles/_panel-header.scss | 3 + libs/ui/styles/_portal-layout.scss | 63 +++++++++++++++++++ ...pace-settings-context-panel.component.scss | 28 ++++++++- ...space-shell-context-sidebar.component.scss | 54 ++++++++++++++++ .../workspace-shell-header.component.html | 10 ++- .../workspace-shell-header.component.scss | 63 ++++++++++++++++++- .../workspace-shell-rail-links.component.scss | 31 +++++++++ .../workspace-shell-rail.component.scss | 48 +++++++++++++- .../workspace-shell.component.scss | 13 ++++ 19 files changed, 518 insertions(+), 19 deletions(-) create mode 100644 .changes/ui-mobile-layout.md diff --git a/.changes/ui-mobile-layout.md b/.changes/ui-mobile-layout.md new file mode 100644 index 000000000..a6ae38203 --- /dev/null +++ b/.changes/ui-mobile-layout.md @@ -0,0 +1,10 @@ +--- +type: fix +area: ui +issues: [1100] +--- + +IPTVnator is usable on a phone again. The navigation bar no longer sits off +screen, category and channel lists take the full width instead of squeezing +the content into a sliver, the video keeps a usable share of the screen, and +the M3U channel list can be reopened after hiding it. diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index f3b7d22df..a29be1497 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -751,6 +751,9 @@ ); margin-left: 0; margin-right: 0; + // Stretched to the full layout width, so its own inline padding would be + // added on top and push the settings page into a horizontal scroll. + box-sizing: border-box; z-index: 100; display: flex; justify-content: flex-end; diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index e0082a268..7e0e4b39d 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -253,9 +253,11 @@ remain local when the meaning is explicit. width is preserved so uncollapsing restores the user's previous resized width. Both rails share the same 180 ms width transition so motion stays in lockstep. -- Below 600 px viewport, the M3U layout's mobile bottom-drawer rule overrides - the desktop collapse to `height: 0` instead of `width: 0`, and the floating - restore handle is hidden. +- At the phone breakpoint the M3U layout's bottom-drawer rule overrides the + desktop collapse to `height: 0` instead of `width: 0`. The floating restore + handle stays visible there: the collapse toggle is reachable by touch, so + hiding the handle left a phone with no way to bring the list back short of + `Cmd/Ctrl+B`. ### EPG Card @@ -331,6 +333,50 @@ Settings use the same system but are flatter than content-heavy views. - Neutral rows can use low-opacity dark overlays - Keep strong blue tint reserved for active sections and selected items +## Phone Layout + +`640px` is the phone breakpoint. Use `@media (max-width: 640px)` rather than +inventing a nearby value: several surfaces cooperate at this width, and a +component that picks `599px` leaves a band where the shell has already stacked +but the component has not. + +### Rails become rows or stacks + +- The workspace shell rail turns into a horizontal top bar. Everything inside + it has to opt into the row direction — a nested list that keeps + `flex-direction: column` stacks its links out of the bar and over the header. + The bar scrolls sideways once a portal contributes its sections, and the + settings link is `position: sticky` so it never scrolls out of reach. +- Side rails stack above the content instead of beside it: the shell context + panel, the live-layout channel sidebar, and the M3U channel drawer. + +### Resizable rails need `!important` + +`ResizableDirective` writes the persisted desktop width as an inline style, so +a phone rule must be `width: 100% !important` to win. Hide `.resize-handle` in +the same rule — dragging is meaningless at full width. Since there is no global +`border-box` reset, a full-width rail with its own padding also needs +`box-sizing: border-box` or it overflows the viewport. + +### State the content's floor, not the list's ceiling + +On routes that stack two lists above the player (live TV shows the categories +panel and the channel list), capping both lists still leaves the video a +sliver. Give the player container a `min-height` instead and let the lists +shrink into what is left. + +### What to drop + +Prefer removing a control over shrinking everything around it: + +- Keyboard-only affordances — the `⌘K` badge, the shortcuts button. +- The `mat-paginator` page-size select, which is the widest part of the + control and the least useful one on a phone. The range and arrows stay. +- Counts and subtitles that a neighbouring control already states. + +Never drop the only way back to a hidden surface. A collapse toggle that is +reachable by touch needs its restore affordance to be reachable too. + ## Theme Guidance ### Light Theme diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss index 246226635..5e9c2fff5 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss @@ -109,18 +109,25 @@ } } -@media only screen and (max-width: 599px) { +// Matches the phone breakpoint in portal-layout's live-layout mixin, which +// this component includes; a narrower one here would leave a band of widths +// where only the mixin's generic stacking applied. +@media only screen and (max-width: 640px) { :host { flex-direction: column-reverse; } .sidebar { - width: 100%; + width: 100% !important; min-width: 100%; max-width: 100%; height: 50vh; + // The mixin caps the list's share; here the explicit height is the + // whole point of the bottom-drawer layout, so release the cap. + max-height: none; border-right: none; border-top: 1px solid var(--mat-sys-outline-variant); + border-bottom: none; // Sidebar collapse uses width=0 on desktop; on mobile the rail flips // to a bottom drawer so collapse it via height instead. @@ -131,11 +138,10 @@ } } + // Keeps the mixin's player floor rather than releasing it: `height: 50vh` + // is a preference, not a reservation, so on a short landscape phone the + // drawer could otherwise squeeze the video away entirely. .content-container { height: 50vh; } - - .sidebar-restore { - display: none; - } } diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss index fdd6f4df9..3ab758990 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss @@ -90,6 +90,25 @@ line-height: 1.3; letter-spacing: 0.02em; color: var(--mat-sys-on-surface-variant); + // A wrapping subtitle pushes the trigger past the 56px header row. + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +// On a phone the header row has no width to spare, and the shell allows this +// trigger to shrink to 88px there. The decorative type icon goes so that the +// trigger's fixed chrome (refresh, chevron, gaps, padding) actually fits that +// floor with the name ellipsizing; the subtitle goes because the name is the +// only part worth the room. +@media (max-width: 640px) { + .trigger-icon-container { + display: none; + } + + .playlist-info .channels-count { + display: none; } } diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss index a55858215..7326e8f65 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss @@ -234,6 +234,13 @@ mat-paginator { mat-paginator { justify-self: start; + + // The page-size select wraps onto a line of its own here, costing a + // row of an already short screen for a control nobody reaches for + // while browsing on a phone. The range and the arrows stay. + ::ng-deep .mat-mdc-paginator-page-size { + display: none; + } } } diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.scss b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.scss index 5bf7163c8..61df3c9ad 100644 --- a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.scss +++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.scss @@ -169,3 +169,23 @@ @include grid.content-grid; padding-bottom: 1rem; } + +// The header packs the title, the type filters and the scope toggle into one +// row. On a phone that row is wider than the screen, and the scope checkbox +// was the part pushed off the right edge, so the row becomes a column. +@media (max-width: 640px) { + .header .header-top { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .header .header-left { + gap: 12px; + } + + .header .header-right { + justify-content: flex-start; + flex-wrap: wrap; + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.scss b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.scss index 14fc6ebc1..a5025ae2c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.scss +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.scss @@ -12,6 +12,11 @@ .category-content-header { @include panel.standard-panel-header($sticky: true); + + // Same reason as the Xtream live header: the paginator does not shrink, + // so it must be able to drop to its own line rather than crowd the meta + // out of existence. + flex-wrap: wrap; } .category-meta { @@ -49,7 +54,9 @@ // painting its own (mismatched) Material surface color. mat-paginator { background: transparent !important; - flex-shrink: 0; + // Its own container already wraps internally, so letting it shrink turns + // a clipped next-page arrow into a second line of the paginator itself. + min-width: 0; --mat-paginator-container-size: 40px; --mat-paginator-enabled-icon-color: var( --app-body-color, @@ -70,3 +77,24 @@ app-grid-list.all-items-grid { scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent) transparent; } + +// Mirrors the Xtream live header: on a phone the page-size select is the +// widest part of the paginator and the least useful one, and the count it +// costs is already stated by the range beside it. +@media (max-width: 640px) { + .category-content-header { + padding-inline: 12px; + } + + .category-subtitle { + display: none; + } + + mat-paginator { + min-width: 0; + + ::ng-deep .mat-mdc-paginator-page-size { + display: none; + } + } +} diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss index 6bbc80b9f..7feced7ec 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss @@ -43,6 +43,11 @@ .category-content-header { @include panel.standard-panel-header($sticky: true); + + // The paginator does not shrink, so on any window narrow enough that it + // and the category meta cannot share a line it took the whole row and the + // meta collapsed underneath it. Let it drop to a line of its own instead. + flex-wrap: wrap; } .category-meta { @@ -59,7 +64,9 @@ mat-paginator { background: transparent !important; - flex-shrink: 0; + // Its own container already wraps internally, so letting it shrink turns + // a clipped next-page arrow into a second line of the paginator itself. + min-width: 0; --mat-paginator-container-size: 40px; --mat-paginator-enabled-icon-color: var( --app-body-color, @@ -116,3 +123,28 @@ app-grid-list.live-all-items-grid { line-height: 1.45; opacity: 0.74; } + +// The header is one flex row of "N channels" plus the paginator. The +// paginator's intrinsic width alone exceeds a phone, which crushed the count +// to zero width and left the two drawn on top of each other. +@media (max-width: 640px) { + .category-content-header { + padding-inline: 12px; + } + + // The paginator already states "1 – 25 of 320" right next to it, and the + // room it costs here is taken straight out of the category title. + .category-subtitle { + display: none; + } + + mat-paginator { + min-width: 0; + + // Dropped for the same reason as the catalog grid: it is the widest + // part of the control and the least useful one on a phone. + ::ng-deep .mat-mdc-paginator-page-size { + display: none; + } + } +} diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index 73ca7f5c8..1126fe6b3 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -542,6 +542,26 @@ } } +// A phone cannot carry the poster and the details side by side: the details +// column drops to ~200px, and the action buttons are then squeezed below +// their own labels ("Play" clipped down to its icon). Stack them instead. +@media (max-width: 640px) { + .hero__content { + flex-direction: column; + align-items: stretch; + padding-inline: 16px; + } + + .poster { + width: 140px; + align-self: center; + } + + .details__title { + text-align: center; + } +} + // ============================================================================ // Animations // ============================================================================ @@ -587,3 +607,14 @@ ngx-skeleton-loader { min-height: 330px; // Fallback aspect-ratio: 2 / 3; } + +// After the rule above so it wins the cascade at equal specificity: that +// 330px skeleton fallback is sized for the desktop poster width, while the +// stacked phone hero renders the poster 140px wide — its aspect-ratio height +// is ~210px, and the leftover minimum was pure empty space pushing the title +// and actions ~120px down on every loaded detail page. +@media (max-width: 640px) { + .poster { + min-height: 0; + } +} diff --git a/libs/ui/styles/_panel-header.scss b/libs/ui/styles/_panel-header.scss index 0738bf244..0d1325636 100644 --- a/libs/ui/styles/_panel-header.scss +++ b/libs/ui/styles/_panel-header.scss @@ -29,6 +29,9 @@ align-items: baseline; gap: $gap; flex: 1; + // `flex: 1` lets this shrink to zero width, and the nowrap subtitle then + // painted outside the box and on top of whatever sits beside it. + overflow: hidden; } @mixin standard-panel-title($font-size: 0.95rem) { diff --git a/libs/ui/styles/_portal-layout.scss b/libs/ui/styles/_portal-layout.scss index 5075f3333..153a6ced8 100644 --- a/libs/ui/styles/_portal-layout.scss +++ b/libs/ui/styles/_portal-layout.scss @@ -178,4 +178,67 @@ width: 100% !important; } } + + // ─── Phone layout ──────────────────────────────────────────────────────── + // Side by side, the 400px channel list leaves nothing for the player on a + // phone, so the two stack instead. The width needs `!important` because + // ResizableDirective writes the persisted desktop width inline; dragging + // is meaningless at full width, so the handle goes too. + @media (max-width: 640px) { + :host { + flex-direction: column; + } + + .sidebar { + width: 100% !important; + min-width: 0 !important; + max-width: none; + flex-shrink: 1; + max-height: 42vh; + // Without a floor of its own the player's minimum can take the + // whole body on a short screen and the list vanishes while still + // marked expanded, leaving no way to pick another channel. + min-height: 72px; + border-right: none; + border-bottom: 1px solid var(--app-separator); + + &.sidebar-collapsed { + max-height: 0; + min-height: 0; + border-bottom-color: transparent; + } + + .resize-handle { + display: none; + } + } + + .content-container { + flex: 1 1 auto; + } + + // Inside that container the guide is `flex: 0 0 ` while the + // video is `flex: 1 1 0`, so shrinking is distributed by basis and + // the video — basis zero — absorbed none of it and collapsed. Here + // the video keeps a floor and the guide is what gives way. + .video-player { + min-height: 96px; + } + + .epg.epg--inline { + flex-shrink: 1; + min-height: 0; + } + } + + // The player's floor, not the list's ceiling, is what has to hold — but + // only where the screen can afford it. A landscape phone already spends + // 30vh on the stacked categories panel, so claiming 240px more here left + // nothing for the channel list; there, the two panes just share what is + // left. Keyed on height because that, not width, is what runs out. + @media (max-width: 640px) and (min-height: 600px) { + .content-container { + min-height: 240px; + } + } } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss index fb7b41080..c439cb7e2 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss @@ -26,10 +26,17 @@ flex: 1; min-height: 0; padding-top: 16px; + display: flex; + flex-direction: column; + overflow: hidden; } +// `height: 100%` here does not resolve against a stretched flex item, so the +// list kept its full content height and painted over the footer whenever the +// panel was shorter than its sections (a phone, or a short desktop window). .settings-sections-list { - height: 100%; + flex: 1; + min-height: 0; } .settings-panel-footer { @@ -42,3 +49,22 @@ .settings-back-button:hover:not(.active):not(.selected) { background: var(--app-selection-surface); } + +// On a landscape phone the stacked panel gets ~108-148px, and this panel's +// fixed chrome (title + footer) alone is about that tall — the section list +// was collapsing to nothing behind an overlapping footer. The rail already +// labels the page, so the caption is what gives way, and the footer sheds +// the padding it carries for taller screens. +@media (max-width: 640px) and (max-height: 599px) { + .panel-title { + display: none; + } + + .settings-panel-body { + padding-top: 8px; + } + + .settings-panel-footer { + padding: 6px 8px calc(6px + env(safe-area-inset-bottom, 0px)); + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.scss index d71915cd3..a5e05bd9f 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.scss @@ -57,3 +57,57 @@ min-height: 0; overflow: hidden; } + +.context-panel--settings app-workspace-settings-context-panel { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +// Below this width the shell stacks the panel above the content instead of +// beside it. `!important` is required because ResizableDirective writes the +// persisted desktop width as an inline style; a 300px panel on a 375px screen +// leaves ~50px for the actual content. Dragging is meaningless when the panel +// spans the full width, so the handle goes away with it. +@media (max-width: 640px) { + .context-panel { + width: 100% !important; + max-width: none !important; + // There is no global border-box reset, so the panel's own padding + // would push a 100%-wide panel past the viewport. + box-sizing: border-box; + height: auto; + // Roughly a third of the screen: enough to show several entries + // without pushing the route's own content off the bottom. + max-height: 30vh; + border-right: none; + border-bottom: 1px solid var(--mat-sys-outline-variant); + } + + .context-panel--collapsed { + max-height: 0; + padding-top: 0; + padding-bottom: 0; + border-bottom-color: transparent; + } + + .context-panel :is(.resize-handle) { + display: none; + } + + .context-panel--route, + .context-panel--sources { + overflow: hidden; + } +} + +// The settings route has no player competing for height — its content is one +// scrollable form — so on a short landscape phone the panel can take a larger +// share. 30vh of a 360px screen is 108px, which the panel's own footer and +// padding consume before a single section row renders. +@media (max-width: 640px) and (max-height: 599px) { + .context-panel--settings { + max-height: max(30vh, 148px); + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index 082312550..153e50838 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -55,7 +55,14 @@ 'WORKSPACE.SHELL.OPEN_COMMAND_PALETTE' | translate " > - {{ commandShortcutLabel }} + + {{ + commandShortcutLabel + }} + bolt } @@ -68,6 +75,7 @@
- @if (showAddPlaylist() || showAccountInfo()) { + @if (showAddPlaylist() || showAccountInfo() || showPlaylistInfo()) {
- @if (showAddPlaylist()) { + @if (showPlaylistInfo()) { } @@ -226,6 +226,17 @@ }} } + @if (showAddPlaylist()) { + + }
} diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts index 2ee742ad7..9be63edc0 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts @@ -295,6 +295,38 @@ describe('PlaylistSwitcherComponent', () => { expect(accountInfoSpy).toHaveBeenCalledTimes(1); }); + it('renders playlist info, account info, and add playlist context actions in the menu', async () => { + await createComponent(); + fixture.componentRef.setInput('showPlaylistInfo', true); + fixture.componentRef.setInput('showAccountInfo', true); + fixture.componentRef.setInput('showAddPlaylist', true); + fixture.detectChanges(); + + const playlistInfoSpy = jest.fn(); + component.playlistInfoRequested.subscribe(playlistInfoSpy); + + component.menuTrigger().openMenu(); + fixture.detectChanges(); + + const actionButtons = Array.from( + document.querySelectorAll( + '.context-actions-section .context-action-item' + ) + ); + const labels = actionButtons.map((button) => + button.textContent?.trim() + ); + + expect(labels).toEqual([ + expect.stringContaining('PLAYLIST_INFO'), + expect.stringContaining('ACCOUNT_INFO'), + expect.stringContaining('ADD_PLAYLIST'), + ]); + + actionButtons[0].click(); + expect(playlistInfoSpy).toHaveBeenCalledTimes(1); + }); + it('opens the menu, syncs overlay width, and checks portal statuses for Xtream playlists', fakeAsync(async () => { await createComponent(); From 3dbfefa3d812c6bf327f84d4de4ee8ec3158bafb Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:52:09 +0200 Subject: [PATCH 03/18] test(stalker): enforce portal auth in the mock and cover the full-portal flow (#1324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(stalker): enforce portal auth in the mock and cover the full-portal flow The mock server implemented neither get_profile nor get_events and validated no auth at all, and the e2e suite imported the portal through /portal.php — which the app classifies as a *simple* portal. The entire authenticated branch (handshake, token, watchdog, re-auth) therefore had zero coverage, right before a series of PRs that reworks exactly that. Mock server: - serve the canonical /stalker_portal/server/load.php endpoint, which enforces the Bearer token and the Infomir MAC format like the real middleware; /portal.php stays tolerant so the existing suite keeps covering the simple-portal branch - auth-store.ts models the parts of Stalker 4.9.35 a client can get wrong: plain-text auth failures with HTTP 200, a handshake that is not yet a session, idempotent token re-presentation, and permanent device_id pinning (including the blank-after-pinned lockout) - add get_profile (status 0/1/2, device conflict, block_msg) and the get_events watchdog; profile advertises watchdog_timeout/timeslot - new login-required scenario MAC and POST /invalidate-session so tests can force a mid-session token loss - the /stalker proxy route now forwards the token as a Bearer header and wraps auth failures in the { payload } envelope, matching web-backend Also moves extractMac into request-mac.ts: importing it from the categories handler dragged the whole data generator into any consumer, which broke unit tests on the workspace alias. E2E: new stalker-auth.e2e.ts asserts handshake precedes get_profile precedes content, that content requests carry the token while the handshake does not, that the plain-text failure body is never rendered, and that the client re-authenticates after the portal drops the session. Co-Authored-By: Claude Fable 5 * fix(mock): address CodeQL findings in the new portal auth code Two genuine defects in the code this PR added: - readBearerToken's /Bearer\s+(.*)$/ backtracks polynomially on "bearer" followed by a long run of spaces; require the token to start with a non-space character instead - the /stalker proxy route read query params as strings without narrowing, so a repeated key (?url=a&url=b) arrives as an array and String.prototype.includes silently changes meaning The remaining three alerts (missing rate limiting x2, sensitive data in a GET query) are web-service hygiene rules aimed at internet-facing services. The mock servers bind to localhost, serve fabricated data, ship in no artifact, and deliberately mirror the real backend proxy's token-in-query contract; a rate limiter would break the E2E suite that hammers them. Exclude only those two apps from analysis via a documented CodeQL config; every shipped path keeps full coverage. Co-Authored-By: Claude Fable 5 * fix(mock): tighten portal-auth fidelity per review Review follow-up on #1324 (Greptile 2xP1, Codex 3xP2), all valid: - adoptToken only accepts tokens the mock actually issued (or the already-bound one). The stock server pins any presented Bearer — handshake is stateless there — but a fixture that does the same cannot catch a client with a broken token pipeline; documented as a deliberate strictness divergence. - /invalidate-session clears tokens but keeps pinned device identity: losing a token never unpins device_id on a real portal, so changed identity after re-auth must still hit the device-conflict branch. - The login-required scenario gates on actual do_auth completion instead of auth_second_step: the app sends auth_second_step=1 on its very first get_profile, so the parameter check was trivially bypassed and the status-2 flow never exercised. do_auth is now the faithful boolean step (non-empty credentials -> {js:true}, recorded; empty -> {js:false}). - /server/load.php — the second URL shape isFullStalkerPortal recognizes — is now served and enforced, directly and through the /stalker proxy predicate, so full-portal tests cannot silently fall into the tolerant branch. Co-Authored-By: Claude Fable 5 * test(stalker): prove content actually reloads after re-authentication Review follow-up on #1324 (Codex P2, valid — the "passes for the wrong reason" class): the re-auth test only polled for a fresh handshake and a negative body-text assertion, both of which pass even if the original content request is never replayed or stays unauthorized. Capture the content token from the initial import, then assert a post-invalidation CONTENT request goes out under a DIFFERENT token and that the ITV categories actually render — the mock only answers content for an adopted token, so this proves the new token round-tripped through get_profile. Verified against a live mock that the token genuinely rotates (old token -> "Authorization failed.", new token -> content). Also documents the second Codex P2: the mock is deliberately strict on /server/load.php (a real portal enforces auth there); the import dialog vs session predicate divergence is a separate app bug the strict endpoint will let a later PR cover. Co-Authored-By: Claude Fable 5 * test(stalker): serialize the portal specs and bind mocks to loopback Review follow-up on #1324 (Codex, 4xP2): - Parallel-reset race: under the workspace `fullyParallel` preset the new auth file ran concurrently with stalker.e2e.ts against one shared mock process, and each `beforeEach` wiped global state (sessions, favorites) mid-assertion in the other. Reproduced locally: both suites green in isolation, two failures when run together. Merged the auth tests into stalker.e2e.ts and pinned the file to `mode: 'serial'`, which also removes the pre-existing race between that file's own tests. 19/19 green across three consecutive runs. - Watchdog was recorded but never asserted, so the suite would stay green if the full-portal workflow stopped pinging or dropped its token — `sendWatchdogPing` swallows failures. Now polls for an authenticated `get_events`. - Both mock servers listened on every interface (stalker: `listen(PORT)` with no host; xtream: an explicit `0.0.0.0` default), which made the CodeQL exclusion's "binds to localhost" rationale untrue. Both now default to `127.0.0.1` with a `HOST` opt-in, and the config comment states plainly what the directory-wide ignore trades away. - Documented that the login-required scenario is HTTP-level only for now: the client's `do_auth` path is dormant and sends empty credentials, so the fixture is waiting on that client-side work rather than claiming end-to-end coverage. Co-Authored-By: Claude Fable 5 * test(stalker): force a real auth failure before asserting it stays hidden Review follow-up on #1324 (Codex 2xP2 + 1xP3), all valid: - The "never surfaces the plain-text auth failure" test only performed a successful import, so its negative body assertions were vacuous. It now imports with a MAC outside the Infomir OUI: the strict endpoint answers get_profile with a bare {status:1}, no token is ever adopted, and every content request keeps returning "Authorization failed." Unlike an invalidated session this cannot be repaired by the client retry, so the failure is genuinely observed (asserted directly against the proxy) and only then checked for not leaking into the UI. - docs/architecture/xtream-mock-server.md still documented the wildcard bind that 4b31f7167 replaced with a loopback default; it now states the new default and the HOST=0.0.0.0 opt-in needed for phone/STB/container. - Removed a dangling "Known app-side gap: the" fragment left in the stalker mock README. Co-Authored-By: Claude Fable 5 * fix(mock): scope /reset by MAC so parallel specs stop wiping each other The re-authentication test passed locally but failed all three CI attempts: no request carried a token, because self-hosted.e2e.ts issues a GLOBAL `POST /reset` against the same mock from a parallel Playwright worker, destroying the session mid-import. Running only stalker.e2e.ts locally never triggered it. Serializing within one file (4b31f7167) could not fix this — the interference is between files. Mock state is per-MAC, so `/reset` now accepts `?macAddress=` and clears only that MAC's data, favorites, session and watchdog counters; the unscoped form is kept for callers that own the whole server. Both spec files now reset only the MACs they own, so no worker can disturb another. Verified: a scoped reset of one MAC leaves another MAC's session intact (and its own dies), and stalker.e2e.ts + self-hosted.e2e.ts run together 23/23 green — the combination that reproduced the CI failure. Co-Authored-By: Claude Fable 5 * test(e2e): scope the last global Stalker reset in sources-pwa helpers Completes 3a93fef0f: that commit scoped self-hosted.e2e.ts but missed resetPwaMockServers, which still wiped the whole Stalker fixture from a third spec file. Scope it to the two MACs this suite owns. The auth tests use dedicated MACs no sibling touches, so portal sessions — the fragile state — can no longer be cleared by a parallel worker. Content MACs still overlap between files, which is harmless: that data is regenerated deterministically from the same seed. Verified with the full interfering set running together: stalker.e2e.ts + self-hosted.e2e.ts + sources-pwa.e2e.ts, 26/26 green. Co-Authored-By: Claude Fable 5 * test(stalker): await the first authenticated content request The re-auth test kept failing on CI (3/3 attempts) with an undefined token while passing locally. My earlier diagnosis — a sibling spec's global /reset — was wrong: the failure survived the scoped-reset fix. Real cause is a race in the test itself. `addFullStalkerPortal` only awaits the route change, so on a slower runner the first authenticated content request has not been recorded yet when the token is read; the sibling test that passes happens to await `.category-item` first. Poll for a content request carrying a token before capturing it. The scoped-reset work stands on its own merits (cross-file resets were a real hazard), it just was not what broke this test. Co-Authored-By: Claude Fable 5 * test(stalker): drop serial mode, batch resets, cover the auth handlers Review round on a44f8135f plus a stability regression I introduced. Codex, both valid: - The proxy route stripped `token` from the forwarded query, so `handshake` never saw a presented token and the idempotent-handshake behaviour I documented was unreachable through the PWA path. The real backend forwards every param except `targetId` *and* sets the header; match it. Verified through the proxy: re-handshake now returns the same token with not_valid 0. - The login-required scenario had no committed test, so the README claim was unbacked. Added auth-handlers.spec.ts (status 2 -> do_auth -> profile, MAC-format rejection, device conflict, idempotent handshake, watchdog). Handlers are called directly because the dispatcher pulls in the faker-based generator, which this project's Jest cannot transform. - Sibling suites now own disjoint MACs (00:1A:79:5F:*) instead of sharing the Stalker suite's, so no reset can reach another suite's state at all. Stability: a baseline run of master passed 23/23 first try while this branch failed a different test each run, so the flakiness was mine. `mode: 'serial'` was a stand-in for isolation that per-MAC scoping now provides properly, and it amplified every flake by aborting the rest of the file; removed. `beforeEach` also fired seven sequential resets — the endpoint now accepts repeated `macAddress` params so a suite clears all of its MACs in one request. Added a retrying POST helper after an ECONNRESET on a control call. Verified: three consecutive runs of stalker + self-hosted + sources-pwa, 26/26 each; 28 mock unit tests; lint clean. Co-Authored-By: Claude Fable 5 * test(stalker): restore serial mode for the shared-scenario file Review follow-up (Codex P2), valid: the previous commit removed `mode: 'serial'` while every `beforeEach` still resets all OWNED_MACS, so under fullyParallel one test in this file could clear another's data or session mid-run. Of the two suggested fixes, serialize rather than give each test its own MAC: the tests here are written against scenario fixtures (default, minimal, embedded-series) whose shapes the assertions encode, so a MAC per test would mean inventing a scenario per test and rewriting pre-existing assertions. Cross-file isolation stays with the disjoint sibling MAC range, which is what serial was wrongly standing in for before. The header now states both levels explicitly so the next reader does not undo one of them. Verified: three consecutive runs of stalker + self-hosted + sources-pwa, 26/26 each. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .github/codeql/codeql-config.yml | 20 ++ .github/workflows/codeql-analysis.yml | 3 + apps/stalker-mock-server/README.md | 58 ++- .../src/app/auth-store.spec.ts | 194 ++++++++++ .../stalker-mock-server/src/app/auth-store.ts | 211 +++++++++++ .../stalker-mock-server/src/app/data-store.ts | 11 + .../src/app/handlers/auth-handlers.spec.ts | 207 +++++++++++ .../src/app/handlers/create-link.handler.ts | 2 +- .../src/app/handlers/do-auth.handler.ts | 38 +- .../src/app/handlers/favorites.handler.ts | 2 +- .../app/handlers/get-all-channels.handler.ts | 2 +- .../app/handlers/get-categories.handler.ts | 12 +- .../src/app/handlers/get-epg-info.handler.ts | 2 +- .../src/app/handlers/get-events.handler.ts | 44 +++ .../src/app/handlers/get-genres.handler.ts | 2 +- .../app/handlers/get-ordered-list.handler.ts | 2 +- .../src/app/handlers/get-profile.handler.ts | 94 +++++ .../src/app/handlers/get-seasons.handler.ts | 2 +- .../src/app/handlers/get-short-epg.handler.ts | 2 +- .../src/app/handlers/handshake.handler.ts | 16 +- .../src/app/request-mac.ts | 17 + .../src/app/routes/dispatch.ts | 42 ++- .../src/app/routes/portal.route.ts | 22 +- apps/stalker-mock-server/src/app/scenarios.ts | 15 + apps/stalker-mock-server/src/main.ts | 134 ++++++- apps/web-e2e/src/e2e-helpers.ts | 36 +- apps/web-e2e/src/self-hosted.e2e.ts | 17 +- apps/web-e2e/src/sources-pwa.helpers.ts | 23 +- apps/web-e2e/src/stalker.e2e.ts | 340 +++++++++++++++++- .../xtream-mock-server/src/app/server.spec.ts | 4 +- apps/xtream-mock-server/src/app/server.ts | 5 +- docs/architecture/stalker-mock-server.md | 66 +++- docs/architecture/xtream-mock-server.md | 10 +- 33 files changed, 1564 insertions(+), 91 deletions(-) create mode 100644 .github/codeql/codeql-config.yml create mode 100644 apps/stalker-mock-server/src/app/auth-store.spec.ts create mode 100644 apps/stalker-mock-server/src/app/auth-store.ts create mode 100644 apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts create mode 100644 apps/stalker-mock-server/src/app/handlers/get-events.handler.ts create mode 100644 apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts create mode 100644 apps/stalker-mock-server/src/app/request-mac.ts diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 000000000..3961241ca --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,20 @@ +name: 'IPTVnator CodeQL config' + +# The mock servers are development/E2E fixtures. They bind to loopback by +# default (HOST=0.0.0.0 is an explicit opt-in for pointing a phone/STB at +# them), serve fabricated data, ship in no released artifact, and deliberately +# imitate the quirks of the upstream IPTV protocols — including reading a +# session token from a GET query string, which is what the real Stalker +# backend proxy does and therefore what the app must be tested against. +# +# CodeQL's web-service hygiene rules (missing rate limiting, sensitive data in +# GET requests) assume an internet-facing service and produce only false +# positives here; a rate limiter on a fixture that the E2E suite hammers would +# actively break the tests. paths-ignore is all-or-nothing per path — CodeQL +# has no per-path rule filter — so this deliberately trades away injection/ +# path-traversal coverage for the two fixture apps, which parse no input +# beyond the local test driver. Everything the app itself ships keeps full +# coverage. +paths-ignore: + - apps/stalker-mock-server + - apps/xtream-mock-server diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7f87287f3..003b8b879 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -47,6 +47,9 @@ jobs: uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} + # Excludes the localhost dev/E2E mock servers from analysis; see the + # config file for why. + config-file: ./.github/codeql/codeql-config.yml # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index 42abe86d2..6bd9fd898 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -25,9 +25,55 @@ nx run-many --targets=serve --projects=stalker-mock-server,web Then in IPTVnator, add a new Stalker portal: -- **Portal URL**: `http://localhost:3210/portal.php` +- **Portal URL**: `http://localhost:3210/portal.php` (tolerant panel-style endpoint) + or `http://localhost:3210/stalker_portal/server/load.php` (canonical Ministra + endpoint — see [Two endpoints](#two-endpoints-tolerant-vs-strict) below) - **MAC Address**: one of the predefined scenarios below (or any MAC for auto-generated data) +## Two endpoints: tolerant vs strict + +The same actions are served at two paths with deliberately different strictness, +because the app treats them differently: a URL containing `/stalker_portal` is +imported as a **full portal** (handshake + token + watchdog), anything else as a +**simple portal** (no authentication at all). + +| Path | Behaviour | +|---|---| +| `/portal.php` | Tolerant. Ignores the Bearer token and the MAC format, like most reseller panels in the wild. | +| `/stalker_portal/server/load.php` | Strict. Enforces the token and the MAC format exactly like the real middleware. | +| `/server/load.php` | Strict. The second full-portal URL shape the app recognizes; enforced identically. | + +> **Known app inconsistency:** `StalkerSessionService.isFullStalkerPortal` +> classifies `/server/load.php` as a full portal, but the import dialog's +> `isFullStalkerPortalUrl` checks only for `/stalker_portal`, so importing a bare +> `…/server/load.php` URL persists `isFullStalkerPortal: false` and the app skips +> the handshake. The mock is deliberately faithful to a **real** portal here +> (that path enforces auth), which makes it the right fixture to drive the +> upcoming fix that unifies those two predicates. Until then, import full +> portals through a `/stalker_portal/...` URL. + +The strict endpoint reproduces the parts of Stalker 4.9.35 that a client can +actually get wrong: + +- Every action except `handshake`, `get_profile`, `get_localization` and + `do_auth` requires `Authorization: Bearer `. +- A token only counts once `get_profile` has adopted it — a handshake alone is + not a session. Adoption is deliberately stricter than the stock server: + only tokens the mock actually issued (or the already-bound one) are + accepted, so a client with a broken token pipeline fails loudly. +- Auth failures come back as **HTTP 200 with a plain-text body** + (`Authorization failed.`, `Unauthorized request.`), never a 401/403. Clients + that only check status codes will silently render nothing. +- The handshake is **idempotent**: presenting the MAC's current token returns + that same token instead of rotating it. +- `device_id`/`device_id2` are pinned to the MAC on first non-empty value; any + later change — including sending them empty again — is a permanent + `device conflict` with the "Your STB is damaged." block message. +- `signature`, `metrics` and `prehash` are accepted and ignored, exactly as the + stock server does. +- The MAC must match the Infomir OUI format (`00:1A:79:XX:XX:XX`) or + `get_profile` answers with a bare `{ status: 1 }`. + ## Predefined Scenario MAC Addresses | MAC Address | Scenario | Description | @@ -40,6 +86,7 @@ Then in IPTVnator, add a new Stalker portal: | `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow | | `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list | | `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing | +| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app cannot finish this flow yet (its `do_auth` path is dormant and sends empty credentials), so the scenario is exercised at the HTTP level only — it exists to receive the upcoming client-side `do_auth` work | | `` | **auto** | MAC bytes used as seed → deterministic unique dataset | ## Configuration @@ -54,7 +101,8 @@ Then in IPTVnator, add a new Stalker portal: | Endpoint | Method | Description | |---|---|---| | `/health` | `GET` | Health check — returns `{ status: "ok" }` | -| `/reset` | `POST` | Clear all in-memory data and favorites (useful between test runs) | +| `/reset` | `POST` | Clear all in-memory data, favorites, sessions and watchdog counters (useful between test runs) | +| `/invalidate-session?macAddress=` | `POST` | Drop that MAC's tokens so the next portal call fails with `Authorization failed.` — lets tests assert the client re-handshakes and retries. Pinned device identity survives, as on a real portal | ## API Coverage @@ -62,8 +110,10 @@ All endpoints are served at `GET /portal.php?action=&...` matching the r | Action | Description | |---|---| -| `handshake` | Returns a mock Bearer token | -| `do_auth` | Returns a mock user profile | +| `handshake` | Issues the access token (idempotent) plus the 5.x `random` nonce and `not_valid` flag | +| `get_profile` | Turns the handshake token into a session; enforces device-id pinning, and on the strict endpoint the MAC format | +| `get_events` | Watchdog ping; records the call and returns an empty event set (never affects authorization, as on a real portal) | +| `do_auth` | Boolean login step: `{js:true}` for non-empty credentials (recorded for the login-required scenario), `{js:false}` otherwise | | `get_categories` | Category list filtered by `type` (itv/vod/series) | | `get_genres` | Genre list (mirrors categories) | | `get_ordered_list` | Paginated content list; if `movie_id` is present → returns seasons | diff --git a/apps/stalker-mock-server/src/app/auth-store.spec.ts b/apps/stalker-mock-server/src/app/auth-store.spec.ts new file mode 100644 index 000000000..c33069bcb --- /dev/null +++ b/apps/stalker-mock-server/src/app/auth-store.spec.ts @@ -0,0 +1,194 @@ +import { Request } from 'express'; +import { + adoptToken, + checkRequestAuthorization, + hasCompletedDoAuth, + invalidateSession, + issueHandshakeToken, + markDoAuthCompleted, + pinDeviceIdentity, + readBearerToken, + resetAuthState, +} from './auth-store'; + +const MAC = '00:1A:79:AA:BB:CC'; + +function request(options: { + action?: string; + mac?: string | null; + token?: string; +}): Request { + const headers: Record = {}; + if (options.mac !== null) { + headers['cookie'] = `mac=${options.mac ?? MAC}; stb_lang=en`; + } + if (options.token) { + headers['authorization'] = `Bearer ${options.token}`; + } + return { + headers, + query: { action: options.action ?? 'get_categories' }, + } as unknown as Request; +} + +describe('stalker mock auth store', () => { + beforeEach(() => { + resetAuthState(); + }); + + it('issues a 32-char uppercase hex token', () => { + const { token } = issueHandshakeToken(MAC); + + expect(token).toMatch(/^[0-9A-F]{32}$/); + }); + + it('returns the stored token unchanged when it is presented again', () => { + const { token } = issueHandshakeToken(MAC); + adoptToken(MAC, token); + + const second = issueHandshakeToken(MAC, token); + + expect(second.token).toBe(token); + expect(second.notValid).toBe(false); + }); + + it('flags not_valid when a stale token is presented', () => { + expect(issueHandshakeToken(MAC, 'STALE').notValid).toBe(true); + }); + + it('rejects a request without a mac cookie', () => { + expect(checkRequestAuthorization(request({ mac: null }), true)).toBe( + 'Unauthorized request.' + ); + }); + + it('rejects an unauthenticated action when enforcement is on', () => { + expect(checkRequestAuthorization(request({}), true)).toBe( + 'Authorization failed.' + ); + }); + + it('allows the handshake/profile/do_auth actions without a token', () => { + for (const action of ['handshake', 'get_profile', 'do_auth']) { + expect(checkRequestAuthorization(request({ action }), true)).toBe( + null + ); + } + }); + + it('allows any action once get_profile adopted the token', () => { + const { token } = issueHandshakeToken(MAC); + adoptToken(MAC, token); + + expect(checkRequestAuthorization(request({ token }), true)).toBe(null); + }); + + it('rejects a token that was never adopted by get_profile', () => { + const { token } = issueHandshakeToken(MAC); + + expect(checkRequestAuthorization(request({ token }), true)).toBe( + 'Authorization failed.' + ); + }); + + it('refuses to adopt a token the handshake never issued', () => { + issueHandshakeToken(MAC); + + // Stricter than the stock server on purpose: a forged or stale token + // must not become a session, or the mock cannot catch a client whose + // token pipeline is broken. + expect(adoptToken(MAC, 'F0RGEDF0RGEDF0RGEDF0RGEDF0RGED12')).toBe(false); + expect( + checkRequestAuthorization( + request({ token: 'F0RGEDF0RGEDF0RGEDF0RGEDF0RGED12' }), + true + ) + ).toBe('Authorization failed.'); + }); + + it('re-adopts the already-bound token', () => { + const { token } = issueHandshakeToken(MAC); + adoptToken(MAC, token); + + expect(adoptToken(MAC, token)).toBe(true); + }); + + it('fails after the session is invalidated so clients must re-authenticate', () => { + const { token } = issueHandshakeToken(MAC); + adoptToken(MAC, token); + invalidateSession(MAC); + + expect(checkRequestAuthorization(request({ token }), true)).toBe( + 'Authorization failed.' + ); + }); + + it('keeps pinned device identity across session invalidation', () => { + pinDeviceIdentity(MAC, 'dev-1', undefined); + invalidateSession(MAC); + + // Losing the token (another device logged in) never unpins device_id + // on a real portal, so a changed identity must still conflict. + expect(pinDeviceIdentity(MAC, 'other', undefined)).toBe( + 'device conflict - device_id mismatch' + ); + }); + + it('tracks do_auth completion per MAC', () => { + expect(hasCompletedDoAuth(MAC)).toBe(false); + + markDoAuthCompleted(MAC); + + expect(hasCompletedDoAuth(MAC)).toBe(true); + expect(hasCompletedDoAuth('00:1A:79:00:00:99')).toBe(false); + }); + + it('never enforces the token when enforcement is off', () => { + expect(checkRequestAuthorization(request({}), false)).toBe(null); + }); + + it('reads the bearer token case-insensitively', () => { + const req = { + headers: { authorization: 'bearer ABC123 ' }, + } as unknown as Request; + + expect(readBearerToken(req)).toBe('ABC123'); + }); + + describe('device identity pinning', () => { + it('stores the first non-empty values', () => { + expect(pinDeviceIdentity(MAC, 'dev-1', 'dev-2')).toBe(null); + expect(pinDeviceIdentity(MAC, 'dev-1', 'dev-2')).toBe(null); + }); + + it('reports a conflict when a pinned device_id changes', () => { + pinDeviceIdentity(MAC, 'dev-1', undefined); + + expect(pinDeviceIdentity(MAC, 'other', undefined)).toBe( + 'device conflict - device_id mismatch' + ); + }); + + it('reports a conflict when a pinned value is later sent empty', () => { + pinDeviceIdentity(MAC, 'dev-1', undefined); + + // This is the real lockout: a client that stops sending the id it + // once pinned is told its STB is damaged. + expect(pinDeviceIdentity(MAC, undefined, undefined)).toBe( + 'device conflict - device_id mismatch' + ); + }); + + it('reports the device_id2 conflict separately', () => { + pinDeviceIdentity(MAC, undefined, 'dev-2'); + + expect(pinDeviceIdentity(MAC, undefined, 'changed')).toBe( + 'device conflict - MAC address mismatch' + ); + }); + + it('accepts identity omitted entirely on a fresh MAC', () => { + expect(pinDeviceIdentity(MAC, undefined, undefined)).toBe(null); + }); + }); +}); diff --git a/apps/stalker-mock-server/src/app/auth-store.ts b/apps/stalker-mock-server/src/app/auth-store.ts new file mode 100644 index 000000000..c32486f5f --- /dev/null +++ b/apps/stalker-mock-server/src/app/auth-store.ts @@ -0,0 +1,211 @@ +import { Request } from 'express'; +import { extractMac } from './request-mac.js'; + +/** + * Session/identity state of the mocked portal. + * + * Modelled on the plaintext Stalker 4.9.35 middleware (`server/lib/stb.class.php`), + * which is the last openly readable ancestor of the encoded 5.x core: + * + * - the handshake token is random and **idempotent** — re-presenting a valid + * token returns the same one instead of rotating it + * - a token only becomes a session once `get_profile` stores it for the MAC + * - `device_id`/`device_id2` are pinned on first non-empty value and a later + * mismatch is a hard, permanent conflict + * - `signature`, `metrics` and `prehash` are accepted but never verified + */ + +interface PortalSession { + /** Token handed out by the last handshake, before get_profile adopts it. */ + pendingToken?: string; + /** Token stored for the MAC — what authorizes non-auth actions. */ + accessToken?: string; + /** Whether do_auth completed with non-empty credentials for this MAC. */ + didAuth?: boolean; + deviceId?: string; + deviceId2?: string; +} + +const sessions = new Map(); + +/** Actions the portal answers without a valid Bearer token. */ +const UNAUTHENTICATED_ACTIONS = new Set([ + 'handshake', + 'get_profile', + 'get_localization', + 'do_auth', +]); + +export type AuthFailure = + | 'Authorization failed.' + | 'Access denied.' + | 'Unauthorized request.'; + +function getSession(mac: string): PortalSession { + const key = mac.toLowerCase(); + if (!sessions.has(key)) { + sessions.set(key, {}); + } + return sessions.get(key) as PortalSession; +} + +/** 32 uppercase hex chars, like `strtoupper(md5(microtime + uniqid))`. */ +function generateToken(mac: string): string { + const entropy = `${mac}:${sessions.size}:${tokenCounter++}`; + let hex = ''; + for (let index = 0; index < 32; index += 1) { + const code = entropy.charCodeAt(index % entropy.length) + index * 31; + hex += (code % 16).toString(16).toUpperCase(); + } + return hex; +} + +let tokenCounter = 0; + +/** + * Issue (or re-confirm) a handshake token. Presenting the MAC's current access + * token returns it unchanged, which is what lets real clients persist tokens + * across restarts. + */ +export function issueHandshakeToken(mac: string, presentedToken?: string): { + token: string; + notValid: boolean; +} { + const session = getSession(mac); + + if (presentedToken && presentedToken === session.accessToken) { + return { token: session.accessToken, notValid: false }; + } + + const token = generateToken(mac); + session.pendingToken = token; + + // The real server only sets not_valid when an auth_url is configured and a + // stale token was presented; mirroring the flag lets clients exercise it. + return { token, notValid: Boolean(presentedToken) }; +} + +/** + * Adopt the handshake token as the MAC's session token (what get_profile + * does). Deliberately STRICTER than the stock server here: 4.9.35 issues + * handshake tokens statelessly and pins whatever Bearer get_profile presents, + * so a forged token would become a session on a real portal. The mock only + * adopts tokens it actually issued (or the already-bound one), so a client + * with a broken or substituted token pipeline fails loudly in tests instead + * of passing by accident. + */ +export function adoptToken(mac: string, token: string): boolean { + const session = getSession(mac); + if (token !== session.pendingToken && token !== session.accessToken) { + return false; + } + session.accessToken = token; + session.pendingToken = undefined; + return true; +} + +/** Record that do_auth completed with non-empty credentials for this MAC. */ +export function markDoAuthCompleted(mac: string): void { + getSession(mac).didAuth = true; +} + +export function hasCompletedDoAuth(mac: string): boolean { + return getSession(mac).didAuth === true; +} + +export function readBearerToken(req: Request): string | undefined { + const header = req.headers['authorization']; + if (typeof header !== 'string') { + return undefined; + } + // `\s+(.*)` would backtrack polynomially on "bearer" + a long run of + // spaces, so require the token to start with a non-space character. + const match = /^Bearer[ \t]+(\S.*)$/i.exec(header.trim()); + return match?.[1]?.trim() || undefined; +} + +/** + * Pin device identity to the MAC. Returns a conflict message when a previously + * stored value is contradicted — including the "blank after pinned" case that + * permanently locks real users out. + */ +export function pinDeviceIdentity( + mac: string, + deviceId: string | undefined, + deviceId2: string | undefined +): string | null { + const session = getSession(mac); + + for (const [field, incoming] of [ + ['deviceId', deviceId], + ['deviceId2', deviceId2], + ] as const) { + const stored = session[field]; + if (!stored) { + if (incoming) { + session[field] = incoming; + } + continue; + } + if (stored !== (incoming ?? '')) { + return field === 'deviceId' + ? 'device conflict - device_id mismatch' + : 'device conflict - MAC address mismatch'; + } + } + + return null; +} + +/** + * Decide whether a request may proceed. `enforce` is false for the reseller-style + * `/portal.php` endpoint, which commonly ignores tokens, and true for the + * canonical `/stalker_portal/server/load.php` endpoint. + */ +export function checkRequestAuthorization( + req: Request, + enforce: boolean +): AuthFailure | null { + const action = String(req.query['action'] ?? ''); + const hasMacCookie = (req.headers['cookie'] ?? '').includes('mac='); + + if (!hasMacCookie) { + return 'Unauthorized request.'; + } + if (!enforce || UNAUTHENTICATED_ACTIONS.has(action)) { + return null; + } + + const session = getSession(extractMac(req)); + const presented = readBearerToken(req); + + if (!session.accessToken || presented !== session.accessToken) { + return 'Authorization failed.'; + } + + return null; +} + +/** + * Drop the MAC's tokens so the next request must re-authenticate. Pinned + * device identity survives on purpose: on a real portal a lost token (another + * device logged in) never unpins device_id, so re-authenticating with a + * changed identity must still hit the device-conflict branch. + */ +export function invalidateSession(mac: string): void { + const session = sessions.get(mac.toLowerCase()); + if (!session) { + return; + } + session.accessToken = undefined; + session.pendingToken = undefined; +} + +export function resetAuthState(mac?: string): void { + if (mac) { + sessions.delete(mac.toLowerCase()); + return; + } + sessions.clear(); + tokenCounter = 0; +} diff --git a/apps/stalker-mock-server/src/app/data-store.ts b/apps/stalker-mock-server/src/app/data-store.ts index 52a16a756..4aca56f4f 100644 --- a/apps/stalker-mock-server/src/app/data-store.ts +++ b/apps/stalker-mock-server/src/app/data-store.ts @@ -44,6 +44,17 @@ export function resetFavorites(mac: string): void { favoritesStore.delete(mac.toLowerCase()); } +/** + * Reset everything cached for one MAC. Preferred over `resetAll()` in tests: + * mock state is per-MAC, so a scoped reset cannot disturb a spec that runs + * concurrently against a different MAC. + */ +export function resetMac(mac: string): void { + const key = mac.toLowerCase(); + portalCache.delete(key); + favoritesStore.delete(key); +} + /** Reset all cached data (exposed via /reset endpoint). */ export function resetAll(): void { portalCache.clear(); diff --git a/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts b/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts new file mode 100644 index 000000000..37d551acd --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts @@ -0,0 +1,207 @@ +import { Request, Response } from 'express'; +import { + checkRequestAuthorization, + resetAuthState, +} from '../auth-store'; +import { handleDoAuth } from './do-auth.handler'; +import { handleGetEvents } from './get-events.handler'; +import { handleGetProfile } from './get-profile.handler'; +import { handleHandshake } from './handshake.handler'; + +/** + * Handler-level coverage for the authentication actions. + * + * The e2e suite drives the app against this server, but the login-required + * flow cannot be reached from the client yet (its `do_auth` path is dormant + * and sends empty credentials), so the scenario is pinned down here rather + * than only asserted in prose. + * + * Handlers are called directly instead of through the dispatcher: the + * dispatcher pulls in every content handler and therefore the faker-based + * data generator, which this project's Jest setup does not transform. + */ + +const LOGIN_REQUIRED_MAC = '00:1A:79:00:00:08'; +const PLAIN_MAC = '00:1A:79:00:00:01'; +const BAD_FORMAT_MAC = 'AA:BB:CC:DD:EE:01'; + +type Handler = ( + req: Request, + res: Response, + options?: { enforceMacFormat?: boolean } +) => void; + +function invoke( + handler: Handler, + query: Record, + options: { + mac?: string; + token?: string; + enforceMacFormat?: boolean; + } = {} +): Record { + const headers: Record = { + cookie: `mac=${options.mac ?? PLAIN_MAC}`, + }; + if (options.token) { + headers['authorization'] = `Bearer ${options.token}`; + } + + let body: unknown; + const req = { query, headers, params: {} } as unknown as Request; + const res = { + json: (data: unknown) => { + body = data; + }, + } as unknown as Response; + + handler(req, res, { enforceMacFormat: options.enforceMacFormat ?? true }); + + return (body as { js: Record }).js; +} + +function contentRequest(mac: string, token?: string): Request { + const headers: Record = { cookie: `mac=${mac}` }; + if (token) { + headers['authorization'] = `Bearer ${token}`; + } + return { + headers, + query: { action: 'get_categories' }, + } as unknown as Request; +} + +describe('stalker mock authentication handlers', () => { + beforeEach(() => { + resetAuthState(); + }); + + it('walks the login-required scenario: status 2 -> do_auth -> profile', () => { + const token = invoke(handleHandshake, { action: 'handshake' }, { + mac: LOGIN_REQUIRED_MAC, + })['token'] as string; + expect(token).toMatch(/^[0-9A-F]{32}$/); + + // The app sends auth_second_step=1 on its very first profile request, + // so that parameter alone must not satisfy the scenario. + expect( + invoke( + handleGetProfile, + { action: 'get_profile', auth_second_step: '1' }, + { mac: LOGIN_REQUIRED_MAC, token } + )['status'] + ).toBe(2); + + // Empty credentials are refused, as the operator billing script would. + expect( + invoke( + handleDoAuth, + { action: 'do_auth', login: '', password: '' }, + { mac: LOGIN_REQUIRED_MAC } + ) as unknown + ).toBe(false); + + expect( + invoke( + handleDoAuth, + { action: 'do_auth', login: 'user', password: 'secret' }, + { mac: LOGIN_REQUIRED_MAC } + ) as unknown + ).toBe(true); + + const profile = invoke( + handleGetProfile, + { action: 'get_profile' }, + { mac: LOGIN_REQUIRED_MAC, token } + ); + expect(profile['status']).toBe(0); + expect(profile['watchdog_timeout']).toBe(120); + + // Only now is the token adopted, so protected actions are authorized. + expect( + checkRequestAuthorization( + contentRequest(LOGIN_REQUIRED_MAC, token), + true + ) + ).toBe(null); + }); + + it('rejects a non-Infomir MAC and never adopts its token', () => { + const token = invoke(handleHandshake, { action: 'handshake' }, { + mac: BAD_FORMAT_MAC, + })['token'] as string; + + expect( + invoke( + handleGetProfile, + { action: 'get_profile' }, + { mac: BAD_FORMAT_MAC, token } + ) + ).toEqual({ status: 1 }); + + expect( + checkRequestAuthorization(contentRequest(BAD_FORMAT_MAC, token), true) + ).toBe('Authorization failed.'); + }); + + it('accepts that MAC on the tolerant endpoint, which skips format checks', () => { + const token = invoke(handleHandshake, { action: 'handshake' }, { + mac: BAD_FORMAT_MAC, + })['token'] as string; + + expect( + invoke( + handleGetProfile, + { action: 'get_profile' }, + { mac: BAD_FORMAT_MAC, token, enforceMacFormat: false } + )['status'] + ).toBe(0); + }); + + it('reports a device conflict once an id was pinned', () => { + const token = invoke(handleHandshake, { action: 'handshake' })[ + 'token' + ] as string; + invoke( + handleGetProfile, + { action: 'get_profile', device_id: 'dev-A' }, + { token } + ); + + const conflict = invoke( + handleGetProfile, + { action: 'get_profile', device_id: 'dev-B' }, + { token } + ); + expect(conflict['msg']).toBe('device conflict - device_id mismatch'); + expect(conflict['block_msg']).toContain('Your STB is damaged'); + }); + + it('returns the handshake nonce and re-confirms an adopted token', () => { + const first = invoke(handleHandshake, { action: 'handshake' }); + const token = first['token'] as string; + expect(first['random']).toEqual(expect.any(String)); + expect(first['not_valid']).toBe(0); + + invoke(handleGetProfile, { action: 'get_profile' }, { token }); + + // Presenting the adopted token returns it unchanged — this is what lets + // real clients persist a token across restarts. + const second = invoke(handleHandshake, { + action: 'handshake', + token, + }); + expect(second['token']).toBe(token); + expect(second['not_valid']).toBe(0); + }); + + it('answers the watchdog with an empty event set', () => { + expect( + invoke(handleGetEvents, { + action: 'get_events', + type: 'watchdog', + init: '1', + }) + ).toEqual({ data: { msgs: 0, additional_services_on: '1' } }); + }); +}); diff --git a/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts index 163ecc225..e3a35d6d2 100644 --- a/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { resolveStreamUrl } from '../data-generator.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; /** * Stalker create_link — returns a playable stream URL. diff --git a/apps/stalker-mock-server/src/app/handlers/do-auth.handler.ts b/apps/stalker-mock-server/src/app/handlers/do-auth.handler.ts index 51afe2a5b..affa24fd7 100644 --- a/apps/stalker-mock-server/src/app/handlers/do-auth.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/do-auth.handler.ts @@ -1,25 +1,25 @@ import { Request, Response } from 'express'; +import { markDoAuthCompleted } from '../auth-store.js'; +import { extractMac } from '../request-mac.js'; /** - * Stalker do_auth — returns user profile / account info. + * Stalker do_auth — the login/password step behind `get_profile` status 2. + * + * The real portal never checks a password itself: it proxies the credentials + * to the operator's billing script and answers a bare boolean. The mock + * accepts any non-empty pair, records the completion so `get_profile` can + * stop answering `status: 2` for the login-required scenario, and rejects + * empty credentials the way a billing script would. */ export function handleDoAuth(req: Request, res: Response): void { - res.json({ - js: { - id: '1', - name: 'Mock User', - login: 'mockuser', - password: '', - status: 'active', - tariff_expired_date: '2099-12-31', - phone: '', - ls: '0', - created: new Date().toISOString(), - updated: new Date().toISOString(), - blocked: '0', - acc_enabled: '1', - max_connections: '1', - active_connections: '0', - }, - }); + const login = String(req.query['login'] ?? ''); + const password = String(req.query['password'] ?? ''); + + if (!login || !password) { + res.json({ js: false }); + return; + } + + markDoAuthCompleted(extractMac(req)); + res.json({ js: true }); } diff --git a/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts b/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts index 10b58dd93..720b5a593 100644 --- a/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts @@ -5,7 +5,7 @@ import { getPortalData, removeFavorite, } from '../data-store.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; import { RawChannel, RawRadioStation, diff --git a/apps/stalker-mock-server/src/app/handlers/get-all-channels.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-all-channels.handler.ts index 7d37396fb..06588bdfb 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-all-channels.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-all-channels.handler.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import { getPortalData } from '../data-store.js'; import { getScenario } from '../scenarios.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; /** * Stalker/Ministra get_all_channels — returns the COMPLETE ITV channel list diff --git a/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts index e1b49293a..8692ef3dc 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import { getPortalData } from '../data-store.js'; +import { extractMac } from '../request-mac.js'; /** * Stalker get_categories — returns category list filtered by type. @@ -22,14 +23,3 @@ export function handleGetCategories(req: Request, res: Response): void { res.json({ js: categories }); } - -export function extractMac(req: Request): string { - const cookie = req.headers['cookie'] ?? ''; - return ( - cookie - .split(';') - .find((c) => c.trim().startsWith('mac=')) - ?.split('=')[1] - ?.trim() ?? '00:00:00:00:00:00' - ); -} diff --git a/apps/stalker-mock-server/src/app/handlers/get-epg-info.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-epg-info.handler.ts index 7c89386fa..4435e233f 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-epg-info.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-epg-info.handler.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { getPortalData } from '../data-store.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; /** * Stalker get_epg_info — returns bulk EPG keyed by channel id. diff --git a/apps/stalker-mock-server/src/app/handlers/get-events.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-events.handler.ts new file mode 100644 index 000000000..32cc4c37a --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-events.handler.ts @@ -0,0 +1,44 @@ +import { Request, Response } from 'express'; +import { extractMac } from '../request-mac.js'; + +/** Per-MAC watchdog bookkeeping so tests can assert the client pings at all. */ +const watchdogPings = new Map(); + +/** + * Stalker watchdog `get_events`. The real portal only uses it for presence + * reporting and event delivery — it never affects authorization — so the mock + * records the ping and returns an empty event set. + */ +export function handleGetEvents(req: Request, res: Response): void { + const mac = extractMac(req).toLowerCase(); + const init = String(req.query['init'] ?? '0'); + const previous = watchdogPings.get(mac); + + watchdogPings.set(mac, { + count: (previous?.count ?? 0) + 1, + lastInit: init, + }); + + res.json({ + js: { + data: { + msgs: 0, + additional_services_on: '1', + }, + }, + }); +} + +export function getWatchdogPings( + mac: string +): { count: number; lastInit: string } | undefined { + return watchdogPings.get(mac.toLowerCase()); +} + +export function resetWatchdogPings(mac?: string): void { + if (mac) { + watchdogPings.delete(mac.toLowerCase()); + return; + } + watchdogPings.clear(); +} diff --git a/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts index bc9e30a9a..5be458b72 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { getPortalData } from '../data-store.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; /** * Stalker get_genres — returns genre list for a content type. diff --git a/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts index ea6c7bb26..5b1e502ef 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { getPortalData } from '../data-store.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; import { RawChannel, RawRadioStation, diff --git a/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts new file mode 100644 index 000000000..463fb8466 --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts @@ -0,0 +1,94 @@ +import { Request, Response } from 'express'; +import { + adoptToken, + hasCompletedDoAuth, + pinDeviceIdentity, + readBearerToken, +} from '../auth-store.js'; +import { getScenario } from '../scenarios.js'; +import { extractMac } from '../request-mac.js'; + +/** `00:1A:79` is the Infomir OUI the stock portal requires by default. */ +const INFOMIR_MAC = /^00:1A:79:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}$/; + +/** + * Stalker get_profile — the step that turns a handshake token into a session. + * + * Reproduces the outcomes of the 4.9.35 middleware: a bare `{status:1}` for a + * malformed MAC, `{status:1, msg, block_msg}` for a device conflict, + * `{status:2}` when the portal wants login/password, and otherwise the profile + * itself. `signature`, `metrics` and `prehash` are accepted and ignored, which + * is exactly what the real server does. + */ +export function handleGetProfile( + req: Request, + res: Response, + options: { enforceMacFormat?: boolean } = {} +): void { + const mac = extractMac(req); + const scenario = getScenario(mac); + + if (options.enforceMacFormat && !INFOMIR_MAC.test(mac.toUpperCase())) { + res.json({ js: { status: 1 } }); + return; + } + + // Gate on the actual do_auth completion, not on auth_second_step: the app + // sends auth_second_step=1 on its very first get_profile, so a parameter + // check would let the login-required flow be bypassed without ever + // exercising status 2 -> do_auth -> profile retry. + if (scenario.requiresLogin && !hasCompletedDoAuth(mac)) { + res.json({ + js: { + status: 2, + template: 'auth', + info: 'Login required', + }, + }); + return; + } + + const conflict = pinDeviceIdentity( + mac, + String(req.query['device_id'] ?? '') || undefined, + String(req.query['device_id2'] ?? '') || undefined + ); + + if (conflict) { + res.json({ + js: { + status: 1, + msg: conflict, + block_msg: 'Your STB is damaged.
Call the provider.', + }, + }); + return; + } + + const token = readBearerToken(req); + if (token) { + adoptToken(mac, token); + } + + res.json({ + js: { + id: '1', + name: 'Mock STB', + mac, + status: 0, + blocked: '0', + fname: 'Mock User', + login: 'mockuser', + stb_type: String(req.query['stb_type'] ?? ''), + hd: String(req.query['hd'] ?? '1'), + // Clients should take their watchdog cadence from these two values + // rather than hardcoding one. + watchdog_timeout: 120, + timeslot: 15, + tariff_plan_id: '1', + tariff_expired_date: '2099-12-31', + locale: 'en', + default_locale: 'en', + }, + }); +} diff --git a/apps/stalker-mock-server/src/app/handlers/get-seasons.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-seasons.handler.ts index 88a6e375a..d109c4521 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-seasons.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-seasons.handler.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import { generateSeasons } from '../data-generator.js'; import { getPortalData } from '../data-store.js'; import { getScenario } from '../scenarios.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; /** * Stalker get_ordered_list with type=series for seasons/episodes. diff --git a/apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts index 441631f1d..2d172257d 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import { generateEpg } from '../data-generator.js'; import { getPortalData } from '../data-store.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; /** * Stalker get_short_epg — returns EPG programs for a channel. diff --git a/apps/stalker-mock-server/src/app/handlers/handshake.handler.ts b/apps/stalker-mock-server/src/app/handlers/handshake.handler.ts index c18354876..6340d09ba 100644 --- a/apps/stalker-mock-server/src/app/handlers/handshake.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/handshake.handler.ts @@ -1,8 +1,13 @@ import { Request, Response } from 'express'; +import { issueHandshakeToken } from '../auth-store.js'; /** - * Stalker handshake — returns a Bearer token. - * Real portals return a JWT; we return a deterministic fake token. + * Stalker handshake — issues the access token. + * + * Like the real middleware the token is opaque and idempotent: presenting the + * MAC's current token returns it unchanged instead of rotating it. `random` is + * the 5.x nonce a real MAG signs into `signature`; the mock returns it so + * clients that read it are exercised. */ export function handleHandshake(req: Request, res: Response): void { const mac = (req.headers['cookie'] ?? '') @@ -11,9 +16,14 @@ export function handleHandshake(req: Request, res: Response): void { ?.split('=')[1] ?.trim() ?? 'unknown'; + const presented = String(req.query['token'] ?? '') || undefined; + const { token, notValid } = issueHandshakeToken(mac, presented); + res.json({ js: { - token: `mock-token-${Buffer.from(mac).toString('base64')}`, + token, + random: `${token.slice(0, 20).toLowerCase()}0123456789abcdef1234`, + not_valid: notValid ? 1 : 0, keep_alive: 180, servertime: Math.floor(Date.now() / 1000), servertimezone: 'Europe/Berlin', diff --git a/apps/stalker-mock-server/src/app/request-mac.ts b/apps/stalker-mock-server/src/app/request-mac.ts new file mode 100644 index 000000000..142e3adb9 --- /dev/null +++ b/apps/stalker-mock-server/src/app/request-mac.ts @@ -0,0 +1,17 @@ +import { Request } from 'express'; + +/** + * Read the MAC the portal identifies the box by. Real Stalker clients send it + * as a `mac=` cookie on every request; the proxy route synthesizes the same + * cookie from its query parameter. + */ +export function extractMac(req: Request): string { + const cookie = req.headers['cookie'] ?? ''; + return ( + cookie + .split(';') + .find((c) => c.trim().startsWith('mac=')) + ?.split('=')[1] + ?.trim() ?? '00:00:00:00:00:00' + ); +} diff --git a/apps/stalker-mock-server/src/app/routes/dispatch.ts b/apps/stalker-mock-server/src/app/routes/dispatch.ts index 4d8aa2420..bc42e9bee 100644 --- a/apps/stalker-mock-server/src/app/routes/dispatch.ts +++ b/apps/stalker-mock-server/src/app/routes/dispatch.ts @@ -1,6 +1,9 @@ import { Request, Response } from 'express'; +import { checkRequestAuthorization } from '../auth-store.js'; import { handleHandshake } from '../handlers/handshake.handler.js'; import { handleDoAuth } from '../handlers/do-auth.handler.js'; +import { handleGetEvents } from '../handlers/get-events.handler.js'; +import { handleGetProfile } from '../handlers/get-profile.handler.js'; import { handleGetAllChannels } from '../handlers/get-all-channels.handler.js'; import { handleGetCategories } from '../handlers/get-categories.handler.js'; import { handleGetOrderedList } from '../handlers/get-ordered-list.handler.js'; @@ -11,17 +14,52 @@ import { handleGetEpgInfo } from '../handlers/get-epg-info.handler.js'; import { handleGetShortEpg } from '../handlers/get-short-epg.handler.js'; import { handleGetGenres } from '../handlers/get-genres.handler.js'; +export interface DispatchOptions { + /** + * Whether the endpoint enforces the Bearer token. The canonical + * `/stalker_portal/server/load.php` path does (like a real portal); the + * reseller-style `/portal.php` alias does not, which is what most panels in + * the wild behave like and what the existing e2e suite relies on. + */ + enforceAuth?: boolean; +} + /** * Shared Stalker action dispatcher. - * Used by both the direct /portal.php route and the /stalker CORS proxy route. + * Used by the direct portal routes and the /stalker CORS proxy route. */ -export default function dispatchPortalAction(req: Request, res: Response): void { +export default function dispatchPortalAction( + req: Request, + res: Response, + options: DispatchOptions = {} +): void { const action = req.query['action'] as string; + const authFailure = checkRequestAuthorization( + req, + options.enforceAuth ?? false + ); + if (authFailure) { + // The real middleware echoes this as a plain-text body with HTTP 200 — + // never a 401/403 — because it exits before the JSON envelope is built. + res.status(200).type('html').send(authFailure); + return; + } + switch (action) { case 'handshake': handleHandshake(req, res); break; + case 'get_profile': + handleGetProfile(req, res, { + // A real portal validates the MAC format by default; the + // tolerant reseller alias does not. + enforceMacFormat: options.enforceAuth ?? false, + }); + break; + case 'get_events': + handleGetEvents(req, res); + break; case 'do_auth': handleDoAuth(req, res); break; diff --git a/apps/stalker-mock-server/src/app/routes/portal.route.ts b/apps/stalker-mock-server/src/app/routes/portal.route.ts index e65f1e413..66b58ee6d 100644 --- a/apps/stalker-mock-server/src/app/routes/portal.route.ts +++ b/apps/stalker-mock-server/src/app/routes/portal.route.ts @@ -1,14 +1,22 @@ import { Router, Request, Response } from 'express'; import dispatchPortalAction from './dispatch.js'; -const router = Router(); - /** * Main Stalker API dispatcher. - * All requests arrive as GET /portal.php?action=&... + * + * Two endpoints are served with the same actions but different strictness: + * `/portal.php` (reseller-panel alias, tolerant) and + * `/stalker_portal/server/load.php` (canonical Ministra path, enforces the + * Bearer token exactly like the real middleware). */ -router.get('/', (req: Request, res: Response) => { - dispatchPortalAction(req, res); -}); +export function createPortalRouter(enforceAuth: boolean): Router { + const router = Router(); -export default router; + router.get('/', (req: Request, res: Response) => { + dispatchPortalAction(req, res, { enforceAuth }); + }); + + return router; +} + +export default createPortalRouter(false); diff --git a/apps/stalker-mock-server/src/app/scenarios.ts b/apps/stalker-mock-server/src/app/scenarios.ts index ec1c4b6b8..cdc156716 100644 --- a/apps/stalker-mock-server/src/app/scenarios.ts +++ b/apps/stalker-mock-server/src/app/scenarios.ts @@ -23,6 +23,8 @@ export interface ScenarioConfig { supportsGetAllChannels?: boolean; /** Replace generated VOD with the shared screenshot-safe poster catalog. */ marketingFixture?: true; + /** Answer `get_profile` with `status: 2` until `auth_second_step=1`. */ + requiresLogin?: true; } /** @@ -109,6 +111,19 @@ export const SCENARIOS: Record = { embeddedSeriesFraction: 0, supportsGetAllChannels: false, }, + '00:1a:79:00:00:08': { + name: 'login-required', + description: + 'Portal answering get_profile with status 2 until do_auth completes', + seed: 8008, + categoryCount: { itv: 4, radio: 4, vod: 4, series: 4 }, + itemsPerCategory: 10, + seasonsPerSeries: 2, + episodesPerSeason: 4, + isSeriesFraction: 0, + embeddedSeriesFraction: 0, + requiresLogin: true, + }, '00:1a:79:00:00:07': { name: 'marketing-demo', description: 'Screenshot-safe portal with 35 original poster movies', diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts index 940620f48..61b278ec5 100644 --- a/apps/stalker-mock-server/src/main.ts +++ b/apps/stalker-mock-server/src/main.ts @@ -2,9 +2,11 @@ import http from 'http'; import { join } from 'node:path'; import express, { Request, Response } from 'express'; import cors from 'cors'; -import portalRouter from './app/routes/portal.route.js'; +import portalRouter, { createPortalRouter } from './app/routes/portal.route.js'; import dispatchPortalAction from './app/routes/dispatch.js'; -import { resetAll } from './app/data-store.js'; +import { invalidateSession, resetAuthState } from './app/auth-store.js'; +import { resetWatchdogPings } from './app/handlers/get-events.handler.js'; +import { resetAll, resetMac } from './app/data-store.js'; import { SCENARIOS } from './app/scenarios.js'; import { buildRequestOrigin, @@ -12,6 +14,10 @@ import { } from './app/marketing-poster-url.js'; const PORT = parseInt(process.env['PORT'] ?? '3210', 10); +// Loopback by default: the fixture serves fabricated but unauthenticated +// content, so it should not be reachable from other hosts unless a dev +// explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at it). +const HOST = process.env['HOST'] ?? '127.0.0.1'; const app = express(); const MARKETING_POSTER_DIRECTORY = join( process.cwd(), @@ -76,9 +82,26 @@ app.use( }) ); -// Stalker portal.php endpoint (direct portal protocol, Electron mode) +// Stalker portal.php endpoint (reseller-panel alias — tolerant, no token check) app.use('/portal.php', portalRouter); +// Canonical Ministra endpoints — enforce the Bearer token and the MAC format +// exactly like the real middleware, so the full-portal auth flow is testable. +// Both URL shapes the app classifies as "full" must land on the strict branch. +app.use('/stalker_portal/server/load.php', createPortalRouter(true)); +app.use('/server/load.php', createPortalRouter(true)); + +/** + * Mirror of the app's full-portal predicates (`isFullStalkerPortal` checks + * `/stalker_portal/` or `/server/load.php`; import-time normalization checks + * `/stalker_portal`). Any URL shape the client would authenticate against must + * be enforced by the proxy too, or tests would silently exercise the tolerant + * branch. + */ +function isFullPortalUrlShape(url: string): boolean { + return url.includes('/stalker_portal') || url.includes('/server/load.php'); +} + /** * CORS proxy compatibility endpoint — mirrors the IPTVnator backend API shape: * GET /stalker?url=&macAddress=&action=&... @@ -89,27 +112,64 @@ app.use('/portal.php', portalRouter); * so no app code changes are required. */ app.get('/stalker', (req: Request, res: Response) => { - const { macAddress, url: _url, ...rest } = req.query as Record; - const mac = macAddress ?? '00:1a:79:00:00:01'; + const { + macAddress, + url: portalUrl, + ...rest + } = req.query as Record; + // `token` deliberately stays in `rest`: the real backend proxy forwards + // every param except `targetId` to the portal *and* sets the Authorization + // header, and `handshake` reads the presented token from the query. Strip + // it here and the idempotent-handshake path becomes untestable. + const token = rest['token']; + + // A repeated query key arrives as an array, so every value used below must + // be narrowed to a string before it reaches a string API. + const asString = (value: unknown): string | undefined => + typeof value === 'string' ? value : undefined; + + const mac = asString(macAddress) ?? '00:1a:79:00:00:01'; + + // The real backend proxy turns the token query param into a Bearer header + // before calling the portal; mirror that so token handling is exercised. + const headers: Record = { cookie: `mac=${mac}` }; + const bearer = asString(token); + if (bearer) { + headers['authorization'] = `Bearer ${bearer}`; + } // Build a lightweight synthetic request. We need a fresh object with mutable // `query` and a Cookie header containing the MAC for the handler helpers. const syntheticReq = { query: rest, - headers: { cookie: `mac=${mac}` }, + headers, params: {}, } as unknown as Request; // Capture the JSON response and wrap it in the proxy envelope { payload: ... } let captured: unknown; + let plainTextBody: string | undefined; const syntheticRes = { json: (data: unknown) => { captured = data; }, - } as unknown as Response; + status: () => syntheticRes, + type: () => syntheticRes, + send: (body: string) => { + plainTextBody = body; + }, + } as unknown as Response & { send: (body: string) => void }; - dispatchPortalAction(syntheticReq, syntheticRes); - res.json({ payload: captured }); + dispatchPortalAction(syntheticReq, syntheticRes, { + // The proxied portal URL decides strictness, matching the direct + // endpoints: every canonical Ministra path shape enforces the token. + enforceAuth: isFullPortalUrlShape(asString(portalUrl) ?? ''), + }); + + // The portal answers auth failures with a plain-text body; the real backend + // proxy still wraps whatever it got in the { payload } envelope, so the + // renderer sees the raw string there rather than a transport error. + res.json({ payload: plainTextBody ?? captured }); }); // Health check @@ -117,10 +177,56 @@ app.get('/health', (_req: Request, res: Response) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); -// Reset all in-memory data (useful between Playwright test runs) -app.post('/reset', (_req: Request, res: Response) => { - resetAll(); - res.json({ status: 'reset', timestamp: new Date().toISOString() }); +/** + * Reset in-memory state between test runs. + * + * `?macAddress=` scopes the reset to that MAC and is what specs should + * use: mock state is per-MAC, so a scoped reset cannot wipe the session of a + * spec file running concurrently in another Playwright worker. Without the + * parameter everything is cleared, which is only safe when nothing else is + * talking to this server. + */ +app.post('/reset', (req: Request, res: Response) => { + const macParam = req.query['macAddress']; + // Repeated `macAddress` params let a suite clear all of its MACs in one + // request instead of one round trip each. + const macs = (Array.isArray(macParam) ? macParam : [macParam]).filter( + (value): value is string => typeof value === 'string' && value !== '' + ); + + if (macs.length > 0) { + for (const mac of macs) { + resetMac(mac); + resetAuthState(mac); + resetWatchdogPings(mac); + } + } else { + resetAll(); + resetAuthState(); + resetWatchdogPings(); + } + + res.json({ + status: 'reset', + ...(macs.length > 0 ? { macs } : {}), + timestamp: new Date().toISOString(), + }); +}); + +/** + * Drop a MAC's session so the next portal request fails with + * `Authorization failed.` — lets e2e assert the client re-handshakes and + * retries instead of surfacing an error. + */ +app.post('/invalidate-session', (req: Request, res: Response) => { + const macParam = req.query['macAddress']; + const mac = typeof macParam === 'string' ? macParam : ''; + if (!mac) { + res.status(400).json({ error: 'macAddress query param is required' }); + return; + } + invalidateSession(mac); + res.json({ status: 'invalidated', mac }); }); // --------------------------------------------------------------------------- @@ -158,7 +264,7 @@ process.on('unhandledRejection', (reason) => { process.stdin.resume(); process.stdin.on('end', () => { /* ignore stdin close */ }); -server.listen(PORT, () => { +server.listen(PORT, HOST, () => { const divider = '─'.repeat(62); console.log(`\n${divider}`); console.log(` 🎬 Stalker Mock Server → http://localhost:${PORT}`); diff --git a/apps/web-e2e/src/e2e-helpers.ts b/apps/web-e2e/src/e2e-helpers.ts index 85dd01470..0a587d1a1 100644 --- a/apps/web-e2e/src/e2e-helpers.ts +++ b/apps/web-e2e/src/e2e-helpers.ts @@ -1,4 +1,4 @@ -import type { Locator } from '@playwright/test'; +import type { APIRequestContext, Locator } from '@playwright/test'; import { expect } from './fixtures'; export async function setInputValue( @@ -18,3 +18,37 @@ export async function setInputValue( await input.type(value); await expect(input).toHaveValue(value); } + +/** + * POST to a mock-server control endpoint, retrying transport errors. + * + * The mock servers are shared by every spec file and Playwright runs those + * files in parallel workers, so a burst of concurrent control requests can + * occasionally be met with ECONNRESET. That is a transport hiccup, not a + * failure of the test under it. + */ +export async function postWithRetry( + request: APIRequestContext, + url: string, + attempts = 3 +): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const response = await request.post(url); + if (response.ok()) { + return; + } + lastError = new Error(`POST ${url} failed: ${response.status()}`); + } catch (error) { + lastError = error; + } + + await new Promise((resolve) => + setTimeout(resolve, 250 * (attempt + 1)) + ); + } + + throw lastError; +} diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index b00a0b5f1..2c1f2f8d9 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -1,5 +1,5 @@ import type { Page } from '@playwright/test'; -import { setInputValue } from './e2e-helpers'; +import { postWithRetry, setInputValue } from './e2e-helpers'; import { expect, test } from './fixtures'; const WEB_BACKEND_URL = 'http://localhost:3333'; @@ -8,7 +8,9 @@ const STALKER_MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; const XTREAM_MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`; const STALKER_MOCK_SERVER = `http://localhost:${STALKER_MOCK_PORT}`; const STALKER_PORTAL_URL = `${STALKER_MOCK_SERVER}/portal.php`; -const DEFAULT_MAC = '00:1A:79:00:00:01'; +// Dedicated MAC: mock state is per-MAC and stalker.e2e.ts runs in a parallel +// worker, so sharing one would let each suite's reset clear the other's state. +const DEFAULT_MAC = '00:1A:79:5F:00:01'; async function installRuntimeConfig(page: Page): Promise { await page.route('**/assets/app-config.js', async (route) => { @@ -101,8 +103,15 @@ function expectRequestsUseTargetId(requests: string[], path: string): void { } test.beforeEach(async ({ page, request }) => { - await request.post(`${XTREAM_MOCK_SERVER}/reset`); - await request.post(`${STALKER_MOCK_SERVER}/reset`); + await postWithRetry(request, `${XTREAM_MOCK_SERVER}/reset`); + // Scope the Stalker reset to the MAC this file uses: a global reset would + // wipe the sessions of stalker.e2e.ts running in a parallel worker. + await postWithRetry( + request, + `${STALKER_MOCK_SERVER}/reset?macAddress=${encodeURIComponent( + DEFAULT_MAC + )}` + ); await installRuntimeConfig(page); await page.goto('/'); }); diff --git a/apps/web-e2e/src/sources-pwa.helpers.ts b/apps/web-e2e/src/sources-pwa.helpers.ts index 5e8f2d722..5fb476e25 100644 --- a/apps/web-e2e/src/sources-pwa.helpers.ts +++ b/apps/web-e2e/src/sources-pwa.helpers.ts @@ -1,6 +1,6 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test'; import { expect } from './fixtures'; -import { setInputValue } from './e2e-helpers'; +import { postWithRetry, setInputValue } from './e2e-helpers'; import { getRegisteredProviderUrl, interceptProviderTargetRegistration, @@ -13,9 +13,11 @@ const STALKER_MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; export const XTREAM_MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`; export const STALKER_MOCK_SERVER = `http://localhost:${STALKER_MOCK_PORT}`; export const STALKER_PORTAL_URL = `${STALKER_MOCK_SERVER}/portal.php`; -export const EDITED_MAC = '00:1A:79:00:00:03'; +export const EDITED_MAC = '00:1A:79:5F:00:03'; -const DEFAULT_MAC = '00:1A:79:00:00:01'; +// Dedicated MACs (see EDITED_MAC): never share a MAC with stalker.e2e.ts, +// whose parallel worker would otherwise have its state reset mid-test. +const DEFAULT_MAC = '00:1A:79:5F:00:02'; const M3U_PLAYLIST_URL = `${XTREAM_MOCK_SERVER}/playlist.m3u`; type RuntimeErrors = { @@ -31,11 +33,22 @@ type SourceDialogField = | 'title' | 'username'; +/** + * Reset the fixtures this suite uses. The Stalker reset is scoped to the MACs + * touched here: that mock is shared with stalker.e2e.ts, which runs in a + * parallel Playwright worker, and a global reset would destroy its portal + * sessions mid-test. + */ export async function resetPwaMockServers( request: APIRequestContext ): Promise { - await request.post(`${XTREAM_MOCK_SERVER}/reset`); - await request.post(`${STALKER_MOCK_SERVER}/reset`); + await postWithRetry(request, `${XTREAM_MOCK_SERVER}/reset`); + for (const mac of [DEFAULT_MAC, EDITED_MAC]) { + await postWithRetry( + request, + `${STALKER_MOCK_SERVER}/reset?macAddress=${encodeURIComponent(mac)}` + ); + } } export async function interceptPwaProviderRequests( diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 9129bc0ca..f9c7ee264 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -22,11 +22,35 @@ import { * - 3 seasons × 8 episodes per series item * * Tag: @stalker — run only stalker tests with: nx e2e web-e2e --grep "@stalker" + * + * ISOLATION works on two levels, because one mock-server process is shared by + * every spec file and its state is keyed by MAC: + * + * - ACROSS FILES: `beforeEach` resets only the MACs in `OWNED_MACS`, and the + * sibling files that touch this server (self-hosted, sources-pwa) own a + * disjoint `00:1A:79:5F:*` range, so neither can clear the other's state. + * - WITHIN THIS FILE: tests deliberately share scenario MACs (`default`, + * `minimal`, `embedded-series` — their fixture shapes are what the + * assertions are written against), and every `beforeEach` resets all of + * them. Under the workspace-wide `fullyParallel` preset that would let one + * test wipe another's data or session mid-run, so the file pins itself to a + * single worker. + * + * Giving each test its own MAC instead would mean inventing a scenario per + * test; serializing one file is the cheaper trade. */ +test.describe.configure({ mode: 'serial' }); + const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; const MOCK_SERVER = `http://localhost:${MOCK_PORT}`; const PORTAL_URL = `${MOCK_SERVER}/portal.php`; +/** + * Canonical Ministra path. `PORTAL_URL` above is classified by the app as a + * "simple" portal (no handshake, no token, no watchdog); this shape is the + * authenticated branch, which the mock guards like the real middleware. + */ +const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`; const BACKEND_PROXY = `${MOCK_SERVER}/stalker`; /** Default scenario MAC — balanced catalog, 8 categories, 40 items */ @@ -41,6 +65,20 @@ const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05'; /** Legacy pagination MAC — portal without get_all_channels support */ const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06'; +/** + * Dedicated MACs for the full-portal authentication tests. Mock state is keyed + * by MAC, so keeping these distinct from the content scenarios above means an + * auth test can never consume or invalidate a session another test relies on. + * The Infomir OUI matters: the strict endpoint validates the MAC format. + */ +const AUTH_FLOW_MAC = '00:1A:79:AD:00:01'; +const AUTH_REAUTH_MAC = '00:1A:79:AD:00:03'; +/** + * Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for + * it, so no token is ever adopted and content requests fail permanently. + */ +const AUTH_REJECTED_MAC = 'AA:BB:CC:DD:EE:01'; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -76,12 +114,32 @@ async function interceptStalkerRequests(page: Page): Promise { }); } +/** Every MAC this file owns; all are cleared in one batched reset request. */ +const OWNED_MACS = [ + DEFAULT_MAC, + MINIMAL_MAC, + EMBEDDED_SERIES_MAC, + LEGACY_PAGINATION_MAC, + AUTH_FLOW_MAC, + AUTH_REAUTH_MAC, + AUTH_REJECTED_MAC, +]; + +/** + * Reset only the MACs this file owns, in a single request. Mock state is + * per-MAC and sibling spec files talk to the same server from parallel + * workers, so a global reset here would wipe their state mid-test — and + * theirs would wipe ours. + */ async function resetMockServer(request: APIRequestContext): Promise { + const query = OWNED_MACS.map( + (mac) => `macAddress=${encodeURIComponent(mac)}` + ).join('&'); let lastError: unknown; for (let attempt = 0; attempt < 3; attempt += 1) { try { - const response = await request.post(`${MOCK_SERVER}/reset`); + const response = await request.post(`${MOCK_SERVER}/reset?${query}`); if (response.ok()) { return; } @@ -130,6 +188,89 @@ async function addStalkerPortal( await page.waitForURL(/stalker.*vod/); } +/** + * Add a Stalker portal through the canonical Ministra URL, which the app + * imports as a FULL portal: handshake, Bearer token and watchdog. + */ +async function addFullStalkerPortal( + page: Page, + options: { name?: string; mac: string; expectContent?: boolean } +): Promise { + const { name = 'Full Stalker Portal', mac, expectContent = true } = options; + + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); + + await setInputValue(dialog.locator('input#title'), name); + await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL); + await setInputValue(dialog.locator('input#macAddress'), mac); + + const addButton = dialog.getByRole('button', { name: 'Add', exact: true }); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); + await expect(dialog).toBeHidden(); + + if (expectContent) { + await page.waitForURL(/stalker.*vod/, { timeout: 30_000 }); + } +} + +/** Portal actions the app sent, in order, with the token each carried. */ +function recordPortalActions(page: Page): { + actions: string[]; + tokensByAction: Map; +} { + const actions: string[] = []; + const tokensByAction = new Map(); + + page.on('request', (request) => { + const url = new URL(request.url()); + if (!url.pathname.endsWith('/stalker')) { + return; + } + const action = url.searchParams.get('action'); + if (!action) { + return; + } + actions.push(action); + if (!tokensByAction.has(action)) { + tokensByAction.set(action, url.searchParams.get('token')); + } + }); + + return { actions, tokensByAction }; +} + +const CONTENT_ACTIONS = [ + 'get_categories', + 'get_genres', + 'get_ordered_list', + 'get_all_channels', +]; + +/** Every portal request in order, with the token it carried. */ +function recordPortalRequests( + page: Page +): Array<{ action: string; token: string | null }> { + const requests: Array<{ action: string; token: string | null }> = []; + + page.on('request', (request) => { + const url = new URL(request.url()); + if (!url.pathname.endsWith('/stalker')) { + return; + } + const action = url.searchParams.get('action'); + if (!action) { + return; + } + requests.push({ action, token: url.searchParams.get('token') }); + }); + + return requests; +} + // --------------------------------------------------------------------------- // Test setup // --------------------------------------------------------------------------- @@ -517,9 +658,12 @@ test('@stalker mock server reset clears cached state', async ({ request }) => { ); expect(before.ok()).toBeTruthy(); - // Reset - const reset = await request.post(`${MOCK_SERVER}/reset`); + // Scoped reset — a global one would clear MACs owned by parallel specs. + const reset = await request.post( + `${MOCK_SERVER}/reset?macAddress=${encodeURIComponent(DEFAULT_MAC)}` + ); expect(reset.ok()).toBeTruthy(); + expect((await reset.json()).macs).toEqual([DEFAULT_MAC]); // Data is regenerated identically (deterministic seed) const after = await request.get( @@ -702,3 +846,193 @@ test('@stalker series — seasons load for a series item', async ({ // Default scenario has 8 episodes per season expect(seasons[0].series.length).toBe(8); }); + +/** + * Full-portal authentication. The tests above import through the tolerant + * `/portal.php` alias (simple portal, no auth); these use the canonical + * Ministra endpoint, which the mock guards like the real middleware: + * + * - every action except handshake/get_profile/get_localization/do_auth needs + * `Authorization: Bearer ` + * - a token only counts once `get_profile` has adopted it + * - auth failures come back as HTTP 200 with a plain-text body, never a 401 + */ +test.describe('@stalker full portal authentication', () => { + // Importing a full portal costs a handshake, a profile call and the first + // content load — on a cold dev server that alone approaches Playwright's + // 30s default budget. + test.beforeEach(() => { + test.setTimeout(90_000); + }); + + test('handshakes and authenticates before loading content', async ({ + page, + }) => { + const { actions, tokensByAction } = recordPortalActions(page); + + await addFullStalkerPortal(page, { mac: AUTH_FLOW_MAC }); + + // The portal only answers content actions for an adopted token, so + // reaching the VOD categories at all proves the whole chain ran. + await expect(page.locator('.category-item').first()).toBeVisible({ + timeout: 30_000, + }); + + expect(actions).toContain('handshake'); + expect(actions).toContain('get_profile'); + expect(actions.indexOf('handshake')).toBeLessThan( + actions.indexOf('get_profile') + ); + + const contentAction = actions.find((action) => + ['get_categories', 'get_genres'].includes(action) + ); + expect(contentAction).toBeDefined(); + expect(actions.indexOf('get_profile')).toBeLessThan( + actions.indexOf(contentAction as string) + ); + + // Content requests must carry the token; the handshake must not. + expect(tokensByAction.get('handshake')).toBeFalsy(); + expect(tokensByAction.get(contentAction as string)).toBeTruthy(); + + // The full-portal workflow must also keep the watchdog alive — an + // authenticated get_events fires immediately (init=1) on activation. + // Without this assertion the suite would stay green if the watchdog + // wiring silently died, because its failures are swallowed by design. + await expect + .poll(() => actions.includes('get_events'), { timeout: 30_000 }) + .toBe(true); + expect(tokensByAction.get('get_events')).toBeTruthy(); + }); + + test('never surfaces the portal plain-text auth failure as content', async ({ + page, + request, + }) => { + const requests = recordPortalRequests(page); + + // A MAC outside the Infomir OUI makes the strict endpoint answer + // get_profile with a bare {status:1}, so no token is ever adopted and + // every content request keeps returning the plain-text failure. Unlike + // an invalidated session this cannot be repaired by the app's retry, + // which is what makes the negative assertion below meaningful instead + // of vacuous. + const failureBody = await ( + await request.get( + `${BACKEND_PROXY}?url=${encodeURIComponent( + FULL_PORTAL_URL + )}&macAddress=${encodeURIComponent( + AUTH_REJECTED_MAC + )}&action=get_categories&type=vod` + ) + ).json(); + expect(failureBody.payload).toBe('Authorization failed.'); + + await addFullStalkerPortal(page, { + mac: AUTH_REJECTED_MAC, + expectContent: false, + }); + + // The app must have actually hit the failing portal... + await expect + .poll( + () => + requests.filter((entry) => + CONTENT_ACTIONS.includes(entry.action) + ).length, + { timeout: 30_000 } + ) + .toBeGreaterThan(0); + + // ...and must never render the raw portal response as content. A + // portal answers auth failures with HTTP 200 + plain text, so an app + // that trusts the status code would happily paint these strings. + await expect(page.locator('body')).not.toContainText( + 'Authorization failed.' + ); + await expect(page.locator('body')).not.toContainText( + 'Unauthorized request.' + ); + }); + + test('re-authenticates after the portal drops the session', async ({ + page, + request, + }) => { + const requests = recordPortalRequests(page); + + await addFullStalkerPortal(page, { mac: AUTH_REAUTH_MAC }); + + // `addFullStalkerPortal` only awaits the route change, and on a slow + // runner the first authenticated content request has not necessarily + // gone out by then — poll for it instead of reading the log once. + await expect + .poll( + () => + requests.some( + (entry) => + CONTENT_ACTIONS.includes(entry.action) && + entry.token + ), + { timeout: 30_000 } + ) + .toBe(true); + + // The token the initial import authenticated with — recovery must end + // up on a DIFFERENT one, or nothing was actually re-negotiated. + const tokenBeforeInvalidation = requests.find( + (entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token + )?.token; + expect(tokenBeforeInvalidation).toBeTruthy(); + + // Server-side session loss is what a real expired/replaced token looks + // like: the next request gets "Authorization failed." with HTTP 200. + const invalidated = await request.post( + `${MOCK_SERVER}/invalidate-session?macAddress=${encodeURIComponent( + AUTH_REAUTH_MAC + )}` + ); + expect(invalidated.ok()).toBe(true); + + const requestCountBeforeNavigation = requests.length; + + // Navigating to another content type forces a fresh portal request. + await page.getByRole('link', { name: /live|itv/i }).click(); + + // Recovery is only proven end to end when a CONTENT request goes out + // under a freshly negotiated token — a re-handshake alone could still + // leave the original request unreplayed or unauthorized. The mock only + // answers content for an adopted token, so this doubles as proof the + // new token was adopted via get_profile. + await expect + .poll( + () => + requests + .slice(requestCountBeforeNavigation) + .filter( + (entry) => + CONTENT_ACTIONS.includes(entry.action) && + entry.token && + entry.token !== tokenBeforeInvalidation + ).length, + { timeout: 30_000 } + ) + .toBeGreaterThan(0); + + const recovered = requests.slice(requestCountBeforeNavigation); + expect( + recovered.filter((entry) => entry.action === 'handshake').length + ).toBeGreaterThan(0); + + // And the recovered session must actually render: the ITV categories + // can only come from an authorized request against the new token. + await expect(page.locator('.category-item').first()).toBeVisible({ + timeout: 15_000, + }); + + await expect(page.locator('body')).not.toContainText( + 'Authorization failed.' + ); + }); +}); diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts index 9e26995b1..0080c5ec0 100644 --- a/apps/xtream-mock-server/src/app/server.spec.ts +++ b/apps/xtream-mock-server/src/app/server.spec.ts @@ -195,7 +195,7 @@ describe('Xtream mock server factory', () => { describe('Xtream mock environment parsing', () => { it('uses safe defaults and enables control only for the exact flag', () => { expect(parseXtreamMockServerEnvironment({})).toEqual({ - host: '0.0.0.0', + host: '127.0.0.1', port: 3211, }); expect( @@ -225,7 +225,7 @@ describe('Xtream mock environment parsing', () => { IPTVNATOR_XTREAM_MOCK_CONTROL: 'true', IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'ignored', }) - ).toEqual({ host: '0.0.0.0', port: 3211 }); + ).toEqual({ host: '127.0.0.1', port: 3211 }); }); it.each([ diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts index a5aebc464..576e805cb 100644 --- a/apps/xtream-mock-server/src/app/server.ts +++ b/apps/xtream-mock-server/src/app/server.ts @@ -42,7 +42,10 @@ https://example.channels/path-to-file/4.m3u8 `; const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; const DEFAULT_PORT = 3211; -const DEFAULT_NORMAL_HOST = '0.0.0.0'; +// Loopback by default: the fixtures serve fabricated but unauthenticated +// content, so they should not be reachable from other hosts unless a dev +// explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at them). +const DEFAULT_NORMAL_HOST = '127.0.0.1'; const DEFAULT_CONTROL_HOST = '127.0.0.1'; const PERFORMANCE_USERNAME = 'performance'; const PERFORMANCE_PASSWORD = 'performance'; diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index 932c610d4..19b4d93df 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -37,7 +37,67 @@ Stalker portals use MAC address as the primary credential. The mock server follo ### In-Memory Only -No files or databases are written. All state (generated content + favorites) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs. +No files or databases are written. All state (generated content + favorites + portal sessions) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs. + +### Two Endpoints With Different Strictness + +The app decides how to talk to a portal from the shape of its URL: a URL +containing `/stalker_portal` is imported as a **full portal** (handshake, +`Authorization: Bearer`, watchdog), anything else as a **simple portal** with no +authentication at all. The mock therefore serves the same action set at two +paths: + +| Path | Router | Behaviour | +|---|---|---| +| `/portal.php` | `createPortalRouter(false)` | Tolerant: ignores the token and the MAC format, like most reseller panels | +| `/stalker_portal/server/load.php` | `createPortalRouter(true)` | Strict: enforces both, like the real middleware | +| `/server/load.php` | `createPortalRouter(true)` | Strict: the second URL shape `isFullStalkerPortal` recognizes | + +The `/stalker` proxy route applies the same rule through +`isFullPortalUrlShape()` — every URL the client would authenticate against is +enforced, so tests cannot silently fall into the tolerant branch. + +Keeping the tolerant path is what lets the pre-existing e2e suite (which imports +`portal.php`) stay meaningful — it covers the simple-portal branch — while the +strict path finally covers the authenticated branch that had no coverage at all. + +The strict behaviours mirror the plaintext Stalker 4.9.35 middleware +(`server/lib/stb.class.php`), the last openly readable ancestor of the encoded +5.x core: + +- **Plain-text auth failures.** `Authorization failed.` / `Unauthorized request.` + are returned with **HTTP 200** and a `text/html` body, because the real server + `exit`s before the JSON envelope is built. A client checking only status codes + sees "success" and renders nothing. The `/stalker` proxy route still wraps the + body in the `{ payload }` envelope, matching what `apps/web-backend` does. +- **A handshake is not a session.** The token only authorizes requests once + `get_profile` has adopted it for that MAC. Adoption is deliberately + *stricter* than the stock server: 4.9.35 issues handshake tokens statelessly + and pins whatever Bearer `get_profile` presents, so a forged token would + become a session on a real portal — the mock only adopts tokens it actually + issued, so a client with a broken token pipeline fails loudly in tests. +- **Idempotent handshake.** Presenting the MAC's current token returns that same + token, which is what allows real clients to persist tokens across restarts. +- **Device-id pinning.** `device_id`/`device_id2` are stored on first non-empty + value; any later change — including reverting to empty — is a permanent + `device conflict` carrying the "Your STB is damaged." block message. This is + the only identity check the stock server actually enforces. +- **`signature`, `metrics`, `prehash` are ignored**, exactly as upstream ignores + them; they exist for portals with a custom `access_filter.php`. +- **MAC format validation.** Non-Infomir MACs (`00:1A:79:XX:XX:XX`) get a bare + `{ status: 1 }` from `get_profile`. + +- **`do_auth` is a boolean login step.** Non-empty credentials answer + `{js:true}` and are recorded; the `login-required` scenario's `get_profile` + keeps answering `status: 2` until that record exists, because the app sends + `auth_second_step=1` on its very first profile request and a parameter check + alone would be trivially bypassed. + +Session state lives in `src/app/auth-store.ts` and is cleared by `/reset`. +`POST /invalidate-session?macAddress=` drops a single MAC's tokens so +tests can assert the client re-handshakes and retries instead of surfacing an +error; pinned device identity survives invalidation, as it does on a real +portal. ## Data Generation Pipeline @@ -308,6 +368,6 @@ test('browse VOD categories', async ({ page }) => { - **New content types**: Add a new generator function in `data-generator.ts` and a new handler in `handlers/`. - **New scenarios**: Add to `SCENARIOS` in `scenarios.ts`. -- **Stateful session tokens**: `handshake.handler.ts` generates a token from the MAC — extend this to track token expiry for testing re-auth flows. -- **Error simulation**: Add a special MAC or query param to trigger error responses (e.g. 401, 500) for testing error handling in the Stalker store. +- **Session behaviour**: `auth-store.ts` owns tokens and device pinning. Add TTLs or a "token replaced by another device" mode there rather than in the handlers. +- **Error simulation**: Add a special MAC or query param to trigger error responses for testing error handling in the Stalker store. Note that portal-level auth errors are *not* HTTP errors — see [Two Endpoints With Different Strictness](#two-endpoints-with-different-strictness). - **Slow responses**: Add a `MOCK_DELAY_MS` env var and apply it in middleware for testing loading states. diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index 8ca8141ba..8d9105db5 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -116,10 +116,12 @@ The control plane is absent by default. It is mounted only when `IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN` and a literal loopback `HOST` (`127.0.0.1` or `::1`). Every `/__control/*` request must carry that exact value in `x-iptvnator-performance-token`, including `OPTIONS` preflight requests. -Configuration is validated before the HTTP listener opens. Normal development -mode preserves the legacy wildcard bind when `HOST` is unset; control mode -instead defaults to `127.0.0.1` and rejects an explicitly configured -non-loopback host. The Nx serve targets do not pin `PORT`, so an explicit shell +Configuration is validated before the HTTP listener opens. Both modes now +default to `127.0.0.1` when `HOST` is unset — the fixture serves fabricated but +unauthenticated content, so it should not be reachable from other hosts by +accident. Set `HOST=0.0.0.0` explicitly to expose it, which is what you need +when driving the mock from a phone, an STB, a container, or another machine. +Control mode additionally *rejects* an explicitly configured non-loopback host. The Nx serve targets do not pin `PORT`, so an explicit shell value reaches the parser; its no-value default remains `3211`. Use a dedicated port rather than the normal `3211` E2E server: From d44045de3105b951f22575ac036266fcb8ae39a3 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:00:29 +0200 Subject: [PATCH 04/18] test(playlist): fix flaky refresh-preparation worker-event spec (#1331) The spec raced a fixed 160ms sleep against the service's internal rAF + 120ms paint delay that runs before deleteXtreamPlaylistContent is called. Under parallel jest load the sleep could win, asserting before the mocked worker event was ever delivered. Await a deferred resolved by the mock right after it fires onEvent instead, so the assertion is causally ordered after the signal update. Co-authored-by: Claude Fable 5 --- .../lib/playlist-refresh-action.service.spec.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index b87054a3d..6227ed3e2 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -60,10 +60,6 @@ function createAbortError(): Error { return error; } -async function waitForRefreshPreparationPaint(): Promise { - await new Promise((resolve) => setTimeout(resolve, 160)); -} - function createPlaylistMeta( overrides: Partial = {} ): PlaylistMeta { @@ -559,6 +555,13 @@ describe('PlaylistRefreshActionService', () => { hiddenCategories: []; }>(); let confirmPromise: Promise | undefined; + // The service awaits its internal paint delay (rAF + setTimeout) + // before it calls deleteXtreamPlaylistContent, so a fixed sleep here + // races against real timers under parallel jest load. The mock fires + // onEvent synchronously and the service applies it to the signal + // synchronously, so resolving this deferred inside the mock is a + // deterministic "worker event has been applied" signal. + const workerEventDelivered = createDeferred(); databaseService.deleteXtreamPlaylistContent.mockImplementation( ( @@ -576,6 +579,7 @@ describe('PlaylistRefreshActionService', () => { current: 50, total: 100, }); + workerEventDelivered.resolve(); return refresh.promise; } @@ -587,7 +591,7 @@ describe('PlaylistRefreshActionService', () => { ); service.refresh(item); - await waitForRefreshPreparationPaint(); + await workerEventDelivered.promise; expect(service.refreshPreparation()).toEqual({ playlistId: item._id, From aba89d64cf1a74c2113f1f6ddbf2a878d6ea0873 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:01:10 +0200 Subject: [PATCH 05/18] fix(downloads): resume interrupted Xtream VOD transfers (#1329) * fix(downloads): resume interrupted Xtream VOD transfers * fix(downloads): validate partials before resuming * fix(downloads): propagate headers to episode transfers --- .changes/downloads-vod-reliability.md | 7 + CLAUDE.md | 13 + .../src/download-reliability.e2e.ts | 117 +++++++++ .../src/downloads.e2e-support.ts | 62 +++++ .../app/events/database/download-finalize.ts | 22 +- .../database/download-redownload.spec.ts | 33 ++- .../events/database/download-redownload.ts | 5 +- .../database/download-request-headers.spec.ts | 80 +++++++ .../database/download-request-headers.ts | 66 ++++++ .../events/database/download-requests.spec.ts | 60 +++++ .../app/events/database/download-requests.ts | 41 +--- .../events/database/download-reserve.spec.ts | 1 + .../events/database/download-resume.spec.ts | 223 +++++++++++++++++- .../events/database/download-runtime.spec.ts | 33 ++- .../app/events/database/download-transfer.ts | 79 ++++++- .../src/app/events/stream-probe.ts | 10 +- .../src/app/events/xtream.events.ts | 6 +- docs/architecture/download-manager.md | 16 +- .../xtream-portal-compatibility.md | 14 +- .../serial-details.component.spec.ts | 11 + .../serial-details.component.ts | 3 + .../vod-details-downloads.service.ts | 4 +- .../vod-details-route-playback.spec.ts | 3 + .../vod-details-route.actions.spec.ts | 5 + .../interfaces/src/lib/xtream-portal.utils.ts | 9 +- .../season-container/episode-download.util.ts | 14 ++ .../season-container/episode-utils.spec.ts | 34 ++- .../season-container.component.spec.ts | 8 + .../season-container.component.ts | 7 +- 29 files changed, 894 insertions(+), 92 deletions(-) create mode 100644 .changes/downloads-vod-reliability.md create mode 100644 apps/electron-backend-e2e/src/download-reliability.e2e.ts create mode 100644 apps/electron-backend/src/app/events/database/download-request-headers.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-request-headers.ts diff --git a/.changes/downloads-vod-reliability.md b/.changes/downloads-vod-reliability.md new file mode 100644 index 000000000..899f97e5a --- /dev/null +++ b/.changes/downloads-vod-reliability.md @@ -0,0 +1,7 @@ +--- +type: fix +area: downloads +issues: [897, 1289] +--- + +Xtream movie and episode downloads now keep their provider-compatible identity from the first request through legacy retries after source removal. Recoverable connection drops retain validated partials and show a credential-safe code; Retry resumes only with ETag or Last-Modified, otherwise it safely restarts. diff --git a/CLAUDE.md b/CLAUDE.md index 120fb100e..32868bb61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -911,6 +911,19 @@ engine` (restart required) or **Download Manager**: +- Fresh Xtream movie and series-episode downloads propagate the playlist's + User-Agent, Referer, and Origin, defaulting User-Agent to the same + provider-compatible `XTREAM_CLIENT_USER_AGENT` used by API requests and + stream probes. Retry, resume, and missing-file + recovery also add the fallback to legacy Xtream rows that have no stored + User-Agent. Because download rows survive source deletion, a headerless + legacy row whose playlist is already absent receives the same IPTV-player + fallback; a known Stalker row remains unchanged. Allowlisted connection + resets after bytes reach disk retain the partial and show a credential-safe + `DOWNLOAD_NETWORK_INTERRUPTED` code only when the response supplied a strong + ETag or Last-Modified validator. Retry then continues with Range/If-Range; + without a validator it starts from byte zero and overwrites the unverified + partial instead of risking mixed-representation corruption. - The desktop-only manager shares one global download store across the global, Xtream-scoped, and Stalker-scoped routes. Completed movie and grouped-series cards use the global Small/Medium/Large cover-grid tokens; missing completed diff --git a/apps/electron-backend-e2e/src/download-reliability.e2e.ts b/apps/electron-backend-e2e/src/download-reliability.e2e.ts new file mode 100644 index 000000000..13b69fe11 --- /dev/null +++ b/apps/electron-backend-e2e/src/download-reliability.e2e.ts @@ -0,0 +1,117 @@ +import { mkdirSync, readFileSync, statSync } from 'fs'; +import { join } from 'path'; +import type { Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + expect, + launchElectronApp, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; +import { + createInterruptedRangeServer, + INTERRUPTED_RANGE_SERVER_ETAG, + startDownload, +} from './downloads.e2e-support'; + +async function openDownloadsPage(page: Page): Promise { + await page.getByRole('button', { name: 'Open downloads' }).click(); + await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/); +} + +async function getPlaylistId(page: Page, title: string): Promise { + const playlists = await page.evaluate( + async () => (await window.electron?.dbGetAppPlaylistMetas?.()) ?? [] + ); + const playlist = playlists.find((entry) => entry.title === title); + expect(playlist, `playlist "${title}" should exist`).toBeDefined(); + return playlist?._id ?? ''; +} + +test.describe('Electron download reliability', () => { + test('@downloads @electron retains a network-interrupted partial and retries it with HTTP Range', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const rangeServer = await createInterruptedRangeServer(); + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Reset Portal', + username: 'user1', + password: 'pass1', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openDownloadsPage(app.mainWindow); + + const downloadsDir = join(dataDir, 'e2e-reset-downloads'); + mkdirSync(downloadsDir, { recursive: true }); + await app.electronApp.evaluate(({ dialog }, folder) => { + dialog.showOpenDialog = async () => + ({ + canceled: false, + filePaths: [folder], + }) as Awaited>; + }, downloadsDir); + await app.mainWindow + .getByRole('button', { name: 'Change Folder' }) + .click(); + + const playlistId = await getPlaylistId( + app.mainWindow, + 'Reset Portal' + ); + const downloadId = await startDownload(app.mainWindow, { + playlistId, + xtreamId: 9801, + contentType: 'vod', + title: 'E2E Reset Movie', + url: rangeServer.url, + downloadFolder: downloadsDir, + }); + const item = app.mainWindow.getByTestId( + `download-queue-item-${downloadId}` + ); + await expect(item.locator('.download-queue__status')).toContainText( + 'Failed', + { timeout: 30000 } + ); + await expect(item.locator('.download-queue__error')).toContainText( + 'DOWNLOAD_NETWORK_INTERRUPTED (ECONNRESET)' + ); + + const partialPath = join(downloadsDir, 'E2E Reset Movie.mp4.part'); + expect(statSync(partialPath).size).toBe( + rangeServer.interruptedBytes + ); + + await item + .getByRole('button', { name: 'Retry E2E Reset Movie' }) + .click(); + await expect( + app.mainWindow.getByTestId( + `download-library-movie-${downloadId}` + ) + ).toBeVisible({ timeout: 30000 }); + + const resumeRequest = rangeServer.requests.find( + (entry) => entry.range + ); + expect(resumeRequest?.range).toBe( + `bytes=${rangeServer.interruptedBytes}-` + ); + expect(resumeRequest?.ifRange).toBe(INTERRUPTED_RANGE_SERVER_ETAG); + const finalFile = readFileSync( + join(downloadsDir, 'E2E Reset Movie.mp4') + ); + expect(finalFile.equals(rangeServer.payload)).toBe(true); + } finally { + await closeElectronApp(app); + await rangeServer.close(); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/downloads.e2e-support.ts b/apps/electron-backend-e2e/src/downloads.e2e-support.ts index fc7997f78..2ec21c4ab 100644 --- a/apps/electron-backend-e2e/src/downloads.e2e-support.ts +++ b/apps/electron-backend-e2e/src/downloads.e2e-support.ts @@ -31,6 +31,10 @@ interface ThrottledRangeServer { url: string; } +interface InterruptedRangeServer extends ThrottledRangeServer { + interruptedBytes: number; +} + interface TruncatedDownloadServer { close: () => Promise; payload: Buffer; @@ -39,6 +43,7 @@ interface TruncatedDownloadServer { } export const RANGE_SERVER_ETAG = '"e2e-range-etag"'; +export const INTERRUPTED_RANGE_SERVER_ETAG = '"e2e-reset-etag"'; const downloadPlayCaptureKey = '__iptvnatorE2eDownloadPlayPaths'; export function getStalkerSeriesDownloadTarget( @@ -172,6 +177,63 @@ export async function createThrottledRangeServer( }; } +/** + * Resets the first full response after writing a valid prefix, then serves the + * remainder to a Range retry. This matches a provider/proxy connection drop + * without manufacturing a clean EOF. + */ +export async function createInterruptedRangeServer(): Promise { + const payload = Buffer.alloc(64 * 1024, 9); + const interruptedBytes = 16 * 1024; + const requests: RangeServerRequest[] = []; + + const server = createServer((req, res) => { + const range = req.headers.range; + const ifRange = req.headers['if-range']; + requests.push({ + ifRange: typeof ifRange === 'string' ? ifRange : undefined, + range: typeof range === 'string' ? range : undefined, + }); + + const offset = range + ? Number(/^bytes=(\d+)-$/.exec(range)?.[1] ?? Number.NaN) + : 0; + if (range && Number.isFinite(offset)) { + res.writeHead(206, { + 'Content-Length': payload.length - offset, + 'Content-Range': `bytes ${offset}-${payload.length - 1}/${payload.length}`, + 'Content-Type': 'video/mp4', + ETag: INTERRUPTED_RANGE_SERVER_ETAG, + }); + res.end(payload.subarray(offset)); + return; + } + + res.writeHead(200, { + 'Content-Length': payload.length, + 'Content-Type': 'video/mp4', + ETag: INTERRUPTED_RANGE_SERVER_ETAG, + }); + res.write(payload.subarray(0, interruptedBytes), () => { + setTimeout(() => res.socket?.destroy(), 20); + }); + }); + + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const { port } = server.address() as AddressInfo; + + return { + close: () => + new Promise((resolve) => server.close(() => resolve())), + interruptedBytes, + payload, + requests, + url: `http://127.0.0.1:${port}/media/e2e-reset-movie.mp4`, + }; +} + /** * Ends a chunked response cleanly while Content-Range advertises a larger * representation. The runtime therefore retains the valid .part for a Range diff --git a/apps/electron-backend/src/app/events/database/download-finalize.ts b/apps/electron-backend/src/app/events/database/download-finalize.ts index 955fe1ac4..152d1ebff 100644 --- a/apps/electron-backend/src/app/events/database/download-finalize.ts +++ b/apps/electron-backend/src/app/events/database/download-finalize.ts @@ -14,12 +14,16 @@ import type { DownloadTask, TransferProgress, } from './download-task'; -import { describeError, TruncatedTransferError } from './download-transfer'; +import { + describeError, + InterruptedTransferError, + TruncatedTransferError, +} from './download-transfer'; /** * Persistence for a failed startDownload() attempt, after its cancel/pause - * checkpoints have been ruled out. Chooses between: retaining a truncated - * transfer for a Range retry, committing an already-finalized file, + * checkpoints have been ruled out. Chooses between: retaining a recoverable + * partial for a Range retry, committing an already-finalized file, * retaining a completed partial, or the generic delete-partial failure. */ export async function handleDownloadFailure( @@ -28,8 +32,12 @@ export async function handleDownloadFailure( reservation: ReservedPartialDownloadFile | undefined, error: unknown ): Promise { - if (error instanceof TruncatedTransferError && reservation) { - // The short response is retained so a retry can continue the + if ( + (error instanceof TruncatedTransferError || + error instanceof InterruptedTransferError) && + reservation + ) { + // The recoverable partial is retained so a retry can continue the // transfer via Range instead of starting over. await persistCompletedPartialFailure( db, @@ -338,7 +346,9 @@ function canCopyCompletedPartialAfterLinkFailure(error: unknown): boolean { } /** @returns false when a .part exists but could not be deleted. */ -export function removePartialFile(filePath: string | null | undefined): boolean { +export function removePartialFile( + filePath: string | null | undefined +): boolean { try { removePartialDownloadFile(filePath); return true; diff --git a/apps/electron-backend/src/app/events/database/download-redownload.spec.ts b/apps/electron-backend/src/app/events/database/download-redownload.spec.ts index 1514e50b2..1a1d0b91a 100644 --- a/apps/electron-backend/src/app/events/database/download-redownload.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-redownload.spec.ts @@ -3,6 +3,7 @@ interface SetupOptions { cleanupError?: Error; claim?: { changes: number }; fileReappeared?: boolean; + playlistType?: 'xtream' | 'stalker'; row?: Record | null; urlError?: Error; } @@ -25,13 +26,18 @@ async function setup(options: SetupOptions = {}) { url: 'https://example.test/movie.mp4', ...options.row, }; - const limit = jest.fn().mockResolvedValue(row ? [row] : []); + const downloadLimit = jest.fn().mockResolvedValue(row ? [row] : []); + const playlistLimit = jest + .fn() + .mockResolvedValue([{ type: options.playlistType ?? 'stalker' }]); const where = jest.fn().mockResolvedValue(options.claim ?? { changes: 1 }); const set = jest.fn(() => ({ where })); const db = { - select: jest.fn(() => ({ + select: jest.fn((selection?: unknown) => ({ from: jest.fn(() => ({ - where: jest.fn(() => ({ limit })), + where: jest.fn(() => ({ + limit: selection ? playlistLimit : downloadLimit, + })), })), })), update: jest.fn(() => ({ set })), @@ -118,6 +124,27 @@ describe('redownload missing completed file', () => { }); }); + it('adds the Xtream fallback User-Agent to a legacy row', async () => { + const harness = await setup({ + playlistType: 'xtream', + row: { + playlistId: 'playlist-1', + requestHeaders: null, + }, + }); + + await expect(harness.redownloadMissingRequest(42)).resolves.toEqual({ + success: true, + }); + expect(harness.enqueueDownload).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + 'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18', + }, + }) + ); + }); + it('recovers a file that reappeared without updating or enqueueing', async () => { const harness = await setup({ fileReappeared: true }); diff --git a/apps/electron-backend/src/app/events/database/download-redownload.ts b/apps/electron-backend/src/app/events/database/download-redownload.ts index b34f49f2c..fd9aa8d44 100644 --- a/apps/electron-backend/src/app/events/database/download-redownload.ts +++ b/apps/electron-backend/src/app/events/database/download-redownload.ts @@ -6,7 +6,7 @@ import * as schema from '../../database/schema'; import { assertRemoteUrlAllowed } from '../url-safety'; import { isAvailableDownloadFile } from './download-file-availability'; import { removePartialDownloadFile } from './download-file-path'; -import { parseStoredHeaders } from './download-requests'; +import { resolveStoredDownloadHeaders } from './download-request-headers'; import { enqueueDownload } from './download-runtime'; export interface RedownloadMissingResult { @@ -61,6 +61,7 @@ export async function redownloadMissingRequest( } await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true }); + const headers = await resolveStoredDownloadHeaders(db, item); try { removePartialDownloadFile(item.filePath); @@ -102,7 +103,7 @@ export async function redownloadMissingRequest( directory: dirname(item.filePath), fileName: basename(item.filePath), filePath: item.filePath, - headers: parseStoredHeaders(item.requestHeaders), + headers, id: item.id, resumeValidator: null, totalBytes: null, diff --git a/apps/electron-backend/src/app/events/database/download-request-headers.spec.ts b/apps/electron-backend/src/app/events/database/download-request-headers.spec.ts new file mode 100644 index 000000000..050c6df61 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-request-headers.spec.ts @@ -0,0 +1,80 @@ +import type { DownloadsDatabase } from './download-task'; + +function createDatabase(playlistType?: 'xtream' | 'stalker') { + const limit = jest + .fn() + .mockResolvedValue(playlistType ? [{ type: playlistType }] : []); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ limit })), + })), + })), + } as unknown as DownloadsDatabase; + return { db, select: db.select }; +} + +describe('stored download request headers', () => { + it('adds the provider-compatible fallback for a legacy Xtream row', async () => { + const { db } = createDatabase('xtream'); + const { resolveStoredDownloadHeaders } = + await import('./download-request-headers'); + + await expect( + resolveStoredDownloadHeaders(db, { + playlistId: 'playlist-1', + requestHeaders: JSON.stringify({ + Authorization: 'not-allowed', + Referer: 'https://example.test/', + }), + }) + ).resolves.toEqual({ + Referer: 'https://example.test/', + 'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18', + }); + }); + + it('does not add the Xtream fallback to a Stalker row', async () => { + const { db } = createDatabase('stalker'); + const { resolveStoredDownloadHeaders } = + await import('./download-request-headers'); + + await expect( + resolveStoredDownloadHeaders(db, { + playlistId: 'playlist-1', + requestHeaders: null, + }) + ).resolves.toBeUndefined(); + }); + + it('adds the player fallback when a legacy row outlives its deleted source', async () => { + const { db } = createDatabase(); + const { resolveStoredDownloadHeaders } = + await import('./download-request-headers'); + + await expect( + resolveStoredDownloadHeaders(db, { + playlistId: 'deleted-playlist', + requestHeaders: null, + }) + ).resolves.toEqual({ + 'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18', + }); + }); + + it('preserves an explicit stored User-Agent without querying the playlist', async () => { + const { db, select } = createDatabase('xtream'); + const { resolveStoredDownloadHeaders } = + await import('./download-request-headers'); + + await expect( + resolveStoredDownloadHeaders(db, { + playlistId: 'playlist-1', + requestHeaders: JSON.stringify({ + 'User-Agent': 'Custom/1.0', + }), + }) + ).resolves.toEqual({ 'User-Agent': 'Custom/1.0' }); + expect(select).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-request-headers.ts b/apps/electron-backend/src/app/events/database/download-request-headers.ts new file mode 100644 index 000000000..0f19754f6 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-request-headers.ts @@ -0,0 +1,66 @@ +import { XTREAM_CLIENT_USER_AGENT } from '@iptvnator/shared/interfaces'; +import { eq } from 'drizzle-orm'; +import * as schema from '../../database/schema'; +import type { DownloadsDatabase } from './download-task'; + +const STORED_HEADER_ALLOWLIST = ['User-Agent', 'Origin', 'Referer'] as const; + +export function parseStoredHeaders( + value: string | null | undefined +): Record | undefined { + if (!value) { + return undefined; + } + + try { + const parsed = JSON.parse(value) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined; + } + + // Re-apply the write-time allowlist so a tampered or imported + // database row cannot smuggle arbitrary headers into requests. + const entries = parsed as Record; + const headers = STORED_HEADER_ALLOWLIST.reduce>( + (acc, key) => { + const headerValue = entries[key]; + if (typeof headerValue === 'string') { + acc[key] = headerValue; + } + return acc; + }, + {} + ); + return Object.keys(headers).length > 0 ? headers : undefined; + } catch { + return undefined; + } +} + +export async function resolveStoredDownloadHeaders( + db: DownloadsDatabase, + item: { playlistId: string; requestHeaders: string | null | undefined } +): Promise | undefined> { + const headers = parseStoredHeaders(item.requestHeaders); + if (headers?.['User-Agent']?.trim()) { + return headers; + } + + const playlists = await db + .select({ type: schema.playlists.type }) + .from(schema.playlists) + .where(eq(schema.playlists.id, item.playlistId)) + .limit(1); + const playlistType = playlists[0]?.type; + if (playlistType !== undefined && playlistType !== 'xtream') { + return headers; + } + + // Download rows intentionally survive individual playlist deletion. Older + // rows have no stored provider marker, so a missing source must use the + // IPTV-player fallback as the only recoverable identity-compatible default. + return { + ...headers, + 'User-Agent': XTREAM_CLIENT_USER_AGENT, + }; +} diff --git a/apps/electron-backend/src/app/events/database/download-requests.spec.ts b/apps/electron-backend/src/app/events/database/download-requests.spec.ts index 4f6cd4650..8e4af8d69 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.spec.ts @@ -384,6 +384,66 @@ describe('download request metadata snapshots', () => { }); describe('download requests resume', () => { + it('adds the Xtream fallback User-Agent to a legacy paused row', async () => { + jest.resetModules(); + const schema = await import('../../database/schema'); + const row = { + filePath: '/downloads/movie.mp4', + id: 42, + playlistId: 'playlist-1', + requestHeaders: null, + resumeValidator: '"etag-9"', + status: 'paused', + title: 'Movie', + totalBytes: 100, + url: 'https://example.test/movie.mp4', + }; + const downloadLimit = jest.fn().mockResolvedValue([row]); + const playlistLimit = jest.fn().mockResolvedValue([{ type: 'xtream' }]); + const db = { + select: jest.fn(() => ({ + from: jest.fn((table: unknown) => ({ + where: jest.fn(() => ({ + limit: + table === schema.playlists + ? playlistLimit + : downloadLimit, + })), + })), + })), + update: jest.fn(() => ({ + set: jest.fn(() => ({ + where: jest.fn().mockResolvedValue({ changes: 1 }), + })), + })), + }; + const enqueueDownload = jest.fn(); + const authorizer = { + requireAuthorized: jest.fn(async (directory: string) => directory), + } as unknown as DownloadDirectoryAuthorizer; + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../url-safety', () => ({ + assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined), + })); + jest.doMock('./download-runtime', () => ({ enqueueDownload })); + + const { resumeDownloadRequest } = await import('./download-requests'); + await expect( + resumeDownloadRequest(42, '/unused', authorizer) + ).resolves.toEqual({ success: true }); + + expect(enqueueDownload).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + 'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18', + }, + }) + ); + }); + it('enqueues a paused download with stored headers and original target path', async () => { jest.resetModules(); diff --git a/apps/electron-backend/src/app/events/database/download-requests.ts b/apps/electron-backend/src/app/events/database/download-requests.ts index 5793ea0a4..5acc8d25a 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.ts @@ -6,6 +6,7 @@ import * as schema from '../../database/schema'; import { assertRemoteUrlAllowed } from '../url-safety'; import { DownloadDirectoryAuthorizer } from './download-directory-authorization'; import { removePartialDownloadFile } from './download-file-path'; +import { resolveStoredDownloadHeaders } from './download-request-headers'; import { assertDownloadMetadataArtworkDiffersFromStream, assertDownloadMetadataMatchesContentType, @@ -80,40 +81,6 @@ function serializeHeaders( return headers ? JSON.stringify(headers) : null; } -const STORED_HEADER_ALLOWLIST = ['User-Agent', 'Origin', 'Referer'] as const; - -export function parseStoredHeaders( - value: string | null -): Record | undefined { - if (!value) { - return undefined; - } - - try { - const parsed = JSON.parse(value) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return undefined; - } - - // Re-apply the write-time allowlist so a tampered or imported - // database row cannot smuggle arbitrary headers into requests. - const entries = parsed as Record; - const headers = STORED_HEADER_ALLOWLIST.reduce>( - (acc, key) => { - const headerValue = entries[key]; - if (typeof headerValue === 'string') { - acc[key] = headerValue; - } - return acc; - }, - {} - ); - return Object.keys(headers).length > 0 ? headers : undefined; - } catch { - return undefined; - } -} - export async function startDownloadRequest( data: StartDownloadRequest, authorizer: DownloadDirectoryAuthorizer @@ -320,6 +287,7 @@ export async function retryDownloadRequest( const fileName = retainedFilePath ? basename(retainedFilePath) : createFileName(item.title, item.url); + const headers = await resolveStoredDownloadHeaders(db, item); const queuedUpdate = retainedFilePath ? { errorMessage: null, @@ -345,7 +313,7 @@ export async function retryDownloadRequest( directory, fileName, filePath: retainedFilePath, - headers: parseStoredHeaders(item.requestHeaders), + headers, id: item.id, resumeValidator: retainedFilePath ? item.resumeValidator : null, totalBytes: retainedFilePath ? item.totalBytes : null, @@ -388,6 +356,7 @@ export async function resumeDownloadRequest( const fileName = item.filePath ? basename(item.filePath) : createFileName(item.title, item.url); + const headers = await resolveStoredDownloadHeaders(db, item); // Claim the row atomically: a concurrent resume for the same id loses // this conditional update and must not enqueue a second task. @@ -416,7 +385,7 @@ export async function resumeDownloadRequest( directory, fileName, filePath: item.filePath, - headers: parseStoredHeaders(item.requestHeaders), + headers, id: item.id, resumeValidator: item.resumeValidator, totalBytes: item.totalBytes, diff --git a/apps/electron-backend/src/app/events/database/download-reserve.spec.ts b/apps/electron-backend/src/app/events/database/download-reserve.spec.ts index 46941058f..bf8cea9f5 100644 --- a/apps/electron-backend/src/app/events/database/download-reserve.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-reserve.spec.ts @@ -62,6 +62,7 @@ describe('destination collision handling', () => { runtime.enqueueDownload({ ...createTask(), filePath: '/downloads/movie.mp4', + resumeValidator: '"etag-1"', totalBytes: 100, }); await waitForStatus(set, 'completed'); diff --git a/apps/electron-backend/src/app/events/database/download-resume.spec.ts b/apps/electron-backend/src/app/events/database/download-resume.spec.ts index 5688fa4cc..1e68b016f 100644 --- a/apps/electron-backend/src/app/events/database/download-resume.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-resume.spec.ts @@ -11,6 +11,7 @@ interface ResumeHarness { interface ResumeHarnessOptions { partialSize: number; + partialSizeAfterTransferError?: number; response: { data: Readable; headers: Record; @@ -82,7 +83,12 @@ async function setupResumeHarness( })); jest.doMock('./download-file-path', () => ({ getPartialDownloadPath: (filePath: string) => `${filePath}.part`, - getPartialDownloadSize: jest.fn(() => options.partialSize), + getPartialDownloadSize: jest + .fn() + .mockReturnValueOnce(options.partialSize) + .mockReturnValue( + options.partialSizeAfterTransferError ?? options.partialSize + ), removePartialDownloadFile, reserveAvailablePartialDownloadFile: jest.fn( (directory: string, filename: string) => ({ @@ -145,6 +151,34 @@ describe('download resume validation', () => { ); }); + it('restarts without Range when a retained partial has no validator', async () => { + const harness = await setupResumeHarness({ + finalSize: 4, + partialSize: 50, + response: { + data: Readable.from([Buffer.from('full')]), + headers: { 'content-length': '4', etag: '"etag-new"' }, + status: 200, + }, + }); + + harness.runtime.enqueueDownload( + createTask({ + filePath: '/downloads/movie.mp4', + totalBytes: 54, + }) + ); + await waitForStatus(harness.set, 'completed'); + + const requestOptions = + harness.requestWithValidatedRedirects.mock.calls[0][1]; + expect(requestOptions.headers).toEqual({}); + expect(harness.createWriteStream).toHaveBeenCalledWith( + '/downloads/movie.mp4.part', + { flags: 'w' } + ); + }); + it('restarts from byte zero when a resume request is answered with 200', async () => { const harness = await setupResumeHarness({ finalSize: 4, @@ -209,6 +243,7 @@ describe('download resume validation', () => { harness.runtime.enqueueDownload( createTask({ filePath: '/downloads/movie.mp4', + resumeValidator: '"etag-1"', totalBytes: 54, }) ); @@ -245,6 +280,7 @@ describe('download resume validation', () => { harness.runtime.enqueueDownload( createTask({ filePath: '/downloads/movie.mp4', + resumeValidator: '"etag-1"', totalBytes: 100, }) ); @@ -264,6 +300,191 @@ describe('download resume validation', () => { } }); + it('retains received bytes when the connection resets mid-transfer', async () => { + const body = new PassThrough(); + const harness = await setupResumeHarness({ + finalSize: 'enoent', + partialSize: 0, + partialSizeAfterTransferError: 20, + response: { + data: body, + headers: { + 'content-length': '100', + etag: '"etag-reset"', + }, + status: 200, + }, + }); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + harness.runtime.enqueueDownload(createTask()); + while ( + harness.requestWithValidatedRedirects.mock.calls.length < 1 + ) { + await new Promise((resolve) => setImmediate(resolve)); + } + + body.write(Buffer.alloc(20, 'r')); + const resetError = new Error( + 'socket hang up' + ) as NodeJS.ErrnoException; + resetError.code = 'ECONNRESET'; + body.destroy(resetError); + await waitForStatus(harness.set, 'failed'); + + expect(harness.set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 20, + errorMessage: + 'DOWNLOAD_NETWORK_INTERRUPTED (ECONNRESET): Retry to continue from the saved partial file', + filePath: '/downloads/movie.mp4', + status: 'failed', + totalBytes: 100, + }) + ); + expect(harness.set).toHaveBeenCalledWith( + expect.objectContaining({ + resumeValidator: '"etag-reset"', + }) + ); + expect(harness.removePartialDownloadFile).not.toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } + }); + + it('retains an existing partial when a resumed response resets before another byte', async () => { + const body = new PassThrough(); + const harness = await setupResumeHarness({ + finalSize: 'enoent', + partialSize: 40, + partialSizeAfterTransferError: 40, + response: { + data: body, + headers: { 'content-range': 'bytes 40-99/100' }, + status: 206, + }, + }); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + harness.runtime.enqueueDownload( + createTask({ + filePath: '/downloads/movie.mp4', + resumeValidator: '"etag-reset"', + totalBytes: 100, + }) + ); + while ( + harness.requestWithValidatedRedirects.mock.calls.length < 1 + ) { + await new Promise((resolve) => setImmediate(resolve)); + } + + const resetError = new Error( + 'socket hang up' + ) as NodeJS.ErrnoException; + resetError.code = 'ECONNRESET'; + body.destroy(resetError); + await waitForStatus(harness.set, 'failed'); + + expect(harness.set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 40, + errorMessage: + 'DOWNLOAD_NETWORK_INTERRUPTED (ECONNRESET): Retry to continue from the saved partial file', + filePath: '/downloads/movie.mp4', + status: 'failed', + totalBytes: 100, + }) + ); + expect(harness.removePartialDownloadFile).not.toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } + }); + + it.each([ + { + code: 'EUNKNOWN', + headers: { 'content-length': '100' }, + label: 'unknown stream error', + partialSizeAfterTransferError: 20, + }, + { + code: 'ECONNRESET', + headers: {}, + label: 'response without an advertised total', + partialSizeAfterTransferError: 20, + }, + { + code: 'ECONNRESET', + headers: { 'content-length': '100' }, + label: 'response without a representation validator', + partialSizeAfterTransferError: 20, + }, + { + code: 'ECONNRESET', + headers: { 'content-length': '100' }, + label: 'fresh zero-byte failure', + partialSizeAfterTransferError: 0, + }, + { + code: 'ECONNRESET', + headers: { 'content-length': '100' }, + label: 'partial larger than the advertised total', + partialSizeAfterTransferError: 101, + }, + ])( + 'uses generic cleanup for $label', + async ({ code, headers, partialSizeAfterTransferError }) => { + const body = new PassThrough(); + const harness = await setupResumeHarness({ + finalSize: 'enoent', + partialSize: 0, + partialSizeAfterTransferError, + response: { data: body, headers, status: 200 }, + }); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + harness.runtime.enqueueDownload(createTask()); + while ( + harness.requestWithValidatedRedirects.mock.calls.length < 1 + ) { + await new Promise((resolve) => setImmediate(resolve)); + } + + const streamError = new Error( + 'socket hang up' + ) as NodeJS.ErrnoException; + streamError.code = code; + body.destroy(streamError); + await waitForStatus(harness.set, 'failed'); + + expect(harness.removePartialDownloadFile).toHaveBeenCalledWith( + '/downloads/movie.mp4' + ); + expect(harness.set).toHaveBeenCalledWith( + expect.objectContaining({ + errorMessage: 'socket hang up', + filePath: null, + status: 'failed', + }) + ); + } finally { + consoleError.mockRestore(); + } + } + ); + it('captures a strong ETag from the first response for later resumes', async () => { const harness = await setupResumeHarness({ finalSize: 4, diff --git a/apps/electron-backend/src/app/events/database/download-runtime.spec.ts b/apps/electron-backend/src/app/events/database/download-runtime.spec.ts index 9856b8de9..ce818eb86 100644 --- a/apps/electron-backend/src/app/events/database/download-runtime.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-runtime.spec.ts @@ -42,17 +42,16 @@ describe('download runtime pause and resume', () => { it('persists active pause without deleting the partial file', async () => { jest.resetModules(); - const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })); + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); const db = { update: jest.fn(() => ({ set })), }; const removePartialDownloadFile = jest.fn(); const stream = new PassThrough(); const requestWithValidatedRedirects = jest.fn( - async ( - _url: string, - options: { signal?: AbortSignal } - ) => { + async (_url: string, options: { signal?: AbortSignal }) => { options.signal?.addEventListener('abort', () => { stream.destroy(new Error('aborted')); }); @@ -119,10 +118,12 @@ describe('download runtime pause and resume', () => { } }); - it('uses an HTTP Range header when a partial file already exists', async () => { + it('uses an HTTP Range header when a validated partial file already exists', async () => { jest.resetModules(); - const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })); + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); const db = { update: jest.fn(() => ({ set })), }; @@ -168,13 +169,17 @@ describe('download runtime pause and resume', () => { runtime.enqueueDownload({ ...createTask(), filePath: '/downloads/movie.mp4', + resumeValidator: '"etag-1"', }); await waitForCallCount(requestWithValidatedRedirects, 1); expect(requestWithValidatedRedirects).toHaveBeenCalledWith( 'https://example.test/movie.mp4', expect.objectContaining({ - headers: expect.objectContaining({ Range: 'bytes=50-' }), + headers: expect.objectContaining({ + 'If-Range': '"etag-1"', + Range: 'bytes=50-', + }), }), { allowPrivateNetworks: true } ); @@ -184,7 +189,9 @@ describe('download runtime pause and resume', () => { it('deletes a queued resumed partial file when the queued task is canceled', async () => { jest.resetModules(); - const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })); + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); const db = { update: jest.fn(() => ({ set })), }; @@ -271,7 +278,9 @@ describe('download runtime pause and resume', () => { it('retains the partial path when canceling a queued task whose partial cannot be deleted', async () => { jest.resetModules(); - const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })); + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); const db = { update: jest.fn(() => ({ set })) }; const removePartialDownloadFile = jest.fn(() => { throw new Error('EPERM: locked'); @@ -338,7 +347,9 @@ describe('download runtime pause and resume', () => { it('retains the partial path when canceling a paused row whose partial cannot be deleted', async () => { jest.resetModules(); - const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })); + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); const db = { select: jest.fn(() => ({ from: jest.fn(() => ({ diff --git a/apps/electron-backend/src/app/events/database/download-transfer.ts b/apps/electron-backend/src/app/events/database/download-transfer.ts index cb8df4a81..638d89a53 100644 --- a/apps/electron-backend/src/app/events/database/download-transfer.ts +++ b/apps/electron-backend/src/app/events/database/download-transfer.ts @@ -34,12 +34,39 @@ export class TruncatedTransferError extends Error { } } +const RETAINABLE_NETWORK_ERROR_CODES = new Set([ + 'ECONNABORTED', + 'ECONNRESET', + 'EPIPE', + 'ETIMEDOUT', + 'ERR_HTTP2_STREAM_CANCEL', + 'ERR_HTTP2_STREAM_ERROR', + 'ERR_STREAM_PREMATURE_CLOSE', +]); + +export class InterruptedTransferError extends Error { + constructor( + readonly progress: TransferProgress, + networkCode: string + ) { + super( + `DOWNLOAD_NETWORK_INTERRUPTED (${networkCode}): Retry to continue from the saved partial file` + ); + } +} + export async function transferToPartialFile( db: DownloadsDatabase, task: DownloadTask, reservation: ReservedPartialDownloadFile ): Promise { - const resumeOffset = getResumeOffset(task, reservation); + const retainedOffset = getResumeOffset(task, reservation); + const resumeOffset = task.resumeValidator ? retainedOffset : 0; + if (retainedOffset > 0 && resumeOffset === 0) { + console.warn( + `[Downloads] Restarting ${reservation.filename} from the beginning (saved partial has no ETag or Last-Modified validator)` + ); + } const headers = { ...(task.headers ?? {}), @@ -130,6 +157,22 @@ export async function transferToPartialFile( try { await pipeline(readable, output); + } catch (error) { + const interruptedProgress = getInterruptedTransferProgress( + error, + reservation, + effectiveOffset, + totalBytes, + task.resumeValidator + ); + if (interruptedProgress) { + await persistProgress(db, task, interruptedProgress.progress); + throw new InterruptedTransferError( + interruptedProgress.progress, + interruptedProgress.networkCode + ); + } + throw error; } finally { abortController.signal.removeEventListener('abort', abortStream); } @@ -141,6 +184,40 @@ export async function transferToPartialFile( return { bytesDownloaded, totalBytes }; } +function getInterruptedTransferProgress( + error: unknown, + reservation: ReservedPartialDownloadFile, + initialBytes: number, + totalBytes: number | null, + resumeValidator: string | null | undefined +): { networkCode: string; progress: TransferProgress } | null { + const networkCode = + error && typeof error === 'object' && 'code' in error + ? String(error.code) + : ''; + if ( + !RETAINABLE_NETWORK_ERROR_CODES.has(networkCode) || + totalBytes === null || + !resumeValidator + ) { + return null; + } + + const bytesDownloaded = getPartialDownloadSize(reservation.path); + if ( + bytesDownloaded === 0 || + bytesDownloaded < initialBytes || + bytesDownloaded >= totalBytes + ) { + return null; + } + + return { + networkCode, + progress: { bytesDownloaded, totalBytes }, + }; +} + function getResumeOffset( task: DownloadTask, reservation: ReservedPartialDownloadFile diff --git a/apps/electron-backend/src/app/events/stream-probe.ts b/apps/electron-backend/src/app/events/stream-probe.ts index 760b6f20f..1870c773f 100644 --- a/apps/electron-backend/src/app/events/stream-probe.ts +++ b/apps/electron-backend/src/app/events/stream-probe.ts @@ -13,13 +13,10 @@ import axios, { AxiosRequestConfig } from 'axios'; import { ipcMain } from 'electron'; +import { XTREAM_CLIENT_USER_AGENT } from '@iptvnator/shared/interfaces'; import { UnsafeUrlError } from './url-safety'; import { requestWithValidatedRedirects } from '../util/validated-axios'; -// Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to -// challenge generic browser-looking User-Agents while allowlisting known -// IPTV player clients. A VLC-style User-Agent reliably passes those checks. -const PROBE_CLIENT_USER_AGENT = 'VLC/3.0.18 LibVLC/3.0.18'; const PROBE_TIMEOUT_MS = 10000; export interface StreamProbePayload { @@ -64,7 +61,7 @@ export async function runStreamProbe( // The playlist's own User-Agent wins when it has one: the default // below is a guess that merely gets past most WAFs, while that // one is what the server was configured to expect. - 'User-Agent': payload.userAgent?.trim() || PROBE_CLIENT_USER_AGENT, + 'User-Agent': payload.userAgent?.trim() || XTREAM_CLIENT_USER_AGENT, ...(payload.referer?.trim() ? { Referer: payload.referer.trim() } : {}), @@ -95,8 +92,7 @@ export async function runStreamProbe( ); // A ranged GET opens a stream we never read — release it immediately. const responseBody = response.data as - | { destroy?: () => void } - | undefined; + { destroy?: () => void } | undefined; responseBody?.destroy?.(); return { status: response.status, diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 6b43a3975..30f7349f4 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -8,6 +8,7 @@ import { ipcMain } from 'electron'; import { PortalDebugEvent, XTREAM_CANCEL_SESSION, + XTREAM_CLIENT_USER_AGENT, XTREAM_MAIN_PERFORMANCE_PHASE, normalizeXtreamServerUrl, } from '@iptvnator/shared/interfaces'; @@ -20,11 +21,6 @@ import { } from './xtream-performance'; import { cancelXtreamSessionRequests } from './xtream-session-cancellation'; -// Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to -// challenge generic browser-looking User-Agents while allowlisting known -// IPTV player clients. A VLC-style User-Agent reliably passes those checks. -const XTREAM_CLIENT_USER_AGENT = 'VLC/3.0.18 LibVLC/3.0.18'; - export default class XtreamEvents { static bootstrapXtreamEvents(): Electron.IpcMain { return ipcMain; diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 42825f6fc..1ccd77cbd 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -12,7 +12,7 @@ variants, contextual buttons, and theme-aware styling. - **Queue control (`apps/electron-backend/src/app/events/database/download-runtime.ts`)** `DownloadTask` mirrors a row of the shared `downloads` table (type `Download` in `libs/shared/database/src/lib/schema.ts`) plus transient cancel/pause/progress helpers (shared task types live in `download-task.ts`). Request validation and row creation live in `download-requests.ts`, while `downloads.events.ts` stays focused on IPC registration. `enqueueDownload()` pushes the task onto `downloadQueue` and triggers `processQueue()`. `processQueue()` keeps one active download, updates the row to `downloading`, and calls `startDownload()`. The byte transfer itself lives in `download-transfer.ts`, finalization and retained-partial persistence in `download-finalize.ts`, and the renderer update broadcast in `download-broadcast.ts`. - **Range-aware transfer (`download-transfer.ts`)** - The transfer streams the response through the backend's validated Axios redirect helper instead of `electron-dl`. Headers (user agent, referer, origin) are persisted in `request_headers` and re-applied through the same allowlist when read back on retry/resume. Active pause/cancel operations abort the current request with `AbortController`; pause keeps the partial file and cancel removes it. Resume checks the existing `.part` size (rejecting anything that is not a regular file, so a symlink planted while paused is never followed) and sends `Range: bytes=-` plus `If-Range` with the stored entity validator. The first response's strong `ETag` (or `Last-Modified`) is persisted in `resume_validator` for exactly this purpose. A `206 Partial Content` answer must start at the requested offset (`Content-Range` is verified) before bytes are appended; any other 2xx answer — the server ignoring `Range`, or `If-Range` detecting that the remote file changed — restarts the transfer from byte zero over the same `.part` instead of failing the download. + The transfer streams the response through the backend's validated Axios redirect helper instead of `electron-dl`. Headers (user agent, referer, origin) are persisted in `request_headers` and re-applied through the same allowlist when read back on retry/resume. Fresh Xtream movie and series-episode downloads propagate the playlist's configured headers, using its User-Agent when present and otherwise sharing the provider-compatible `XTREAM_CLIENT_USER_AGENT` used by Xtream API requests and stream probes. Retry, resume, and missing-file recovery resolve the owning playlist type and add that fallback to legacy Xtream rows without a stored User-Agent; known Stalker rows are left unchanged. Download rows deliberately outlive individually deleted playlists, so a headerless legacy row whose source no longer exists receives the same IPTV-player fallback because its original provider type cannot be recovered. Active pause/cancel operations abort the current request with `AbortController`; pause keeps the partial file and cancel removes it. Resume checks the existing `.part` size (rejecting anything that is not a regular file, so a symlink planted while paused is never followed). The first response's strong `ETag` (or `Last-Modified`) is persisted in `resume_validator`; only a partial carrying that validator may send `Range: bytes=-` plus `If-Range` and append bytes. A retained partial without a validator restarts from byte zero and overwrites its `.part`, so a changed remote representation can never be joined to an unverified prefix. A `206 Partial Content` answer must start at the requested offset (`Content-Range` is verified) before bytes are appended; any other 2xx answer — the server ignoring `Range`, or `If-Range` detecting that the remote file changed — restarts the transfer from byte zero over the same `.part` instead of failing the download. - **Destination collision policy** Existing destination files are never overwritten, inspected, or deleted. Before starting a new transfer, the backend atomically reserves a free @@ -23,11 +23,14 @@ variants, contextual buttons, and theme-aware styling. retained `.part` is renamed aside and finalized to the next free numbered destination (`Movie (1).mp4`) instead of resolving the collision by size or `unlink()`. Completion creates the final `filePath` from the `.part` without - overwriting an existing file; cancel and ordinary transfer failures remove - the `.part`, while finalization failures and completed-partial failures - deliberately retain it (the row keeps `filePath` so a later retry can finish - without re-downloading); pause and restart recovery keep it for a later - resume. Re-downloading such a failed row from a detail page + overwriting an existing file; cancel and non-recoverable transfer failures + remove the `.part`, while finalization failures, completed-partial failures, + and allowlisted network interruptions after bytes reached disk with a stored + representation validator deliberately retain it (the row keeps `filePath` + so a later retry can finish without re-downloading); pause and restart + recovery keep partials, but a later retry starts over when no validator was + available. + Re-downloading such a failed row from a detail page (`DOWNLOADS_START`) deletes the retained `.part` before the row is reset. - **Derived file readiness and recovery** `DOWNLOADS_GET_LIST` and `DOWNLOADS_GET` inspect completed destinations on @@ -184,6 +187,7 @@ variants, contextual buttons, and theme-aware styling. - A `.part` that cannot be deleted (locked, permission denied) never loses its database path: cancel persists `canceled` while retaining `filePath` for later cleanup, and `DOWNLOADS_REMOVE` keeps the row and answers `success: false` (surfaced as a snackbar) so retrying the remove re-attempts the deletion once the lock is released. - Resume claims the row atomically (`paused` → `queued` as a conditional update) and the runtime queue rejects duplicate ids, so two rapid Resume clicks racing the status refresh can never produce two transfers for the same download. - A response that ends cleanly before the advertised representation size (for example a proxy that caps each response) is never committed as completed: the transfer fails with `Transfer ended before the advertised size` while retaining the `.part` and `filePath`, so a retry continues via Range from where it stopped. +- An allowlisted mid-response network failure such as `ECONNRESET` is recoverable only when the response advertised a larger total and the `.part` contains valid incomplete bytes. This includes a validated `206` resume that drops before adding another byte. The failed row retains that partial and exposes a stable `DOWNLOAD_NETWORK_INTERRUPTED ()` message without a URL; Retry continues through the same Range/If-Range validation. Pre-response failures, unknown stream errors, filesystem errors, empty fresh failures, and responses without a trustworthy total keep the generic failure path. - Retained `filePath`s recorded in the database stay usable after the user switches download folders — resume/retry of a retained row does not re-require the folder to be the current selection. Fresh downloads still authorize against the currently selected folder. - Startup recovery recognizes a finalization that crashed between creating the final file and committing the row (`downloading` row, no partial, final file present with the recorded size) and marks it `completed` instead of failing it and orphaning the file. - Pause/resume is covered end to end by `apps/electron-backend-e2e/src/downloads.e2e.ts`: a throttled Range-capable mock server verifies the paused `.part` on disk, the `Range`/`If-Range` resume request, and byte-exact assembly of the final file. diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index d0eb29d88..71c1ce632 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -98,9 +98,17 @@ and private-network checks. ## User-Agent -Electron's `XTREAM_REQUEST` and `XTREAM_PROBE_URL` handlers -(`apps/electron-backend/src/app/events/xtream.events.ts`) send a shared -`XTREAM_CLIENT_USER_AGENT` constant on every outgoing request. Some Xtream +Electron's `XTREAM_REQUEST` and stream-probe handlers plus fresh Xtream movie +and series-episode download requests share the exported +`XTREAM_CLIENT_USER_AGENT` fallback. A playlist's explicit User-Agent, +Referer, and Origin are propagated to either download kind; the explicit +User-Agent still wins over the fallback. Legacy +download rows without a stored User-Agent receive the fallback when retrying, +resuming, or recovering a missing completed file. Download rows intentionally +survive individual source deletion; when the playlist row is already gone and +its type can no longer be recovered, a headerless legacy download receives the +same IPTV-player fallback, while a still-identifiable Stalker row remains +unchanged. Some Xtream panels sit behind a WAF (e.g. Cloudflare) configured to challenge generic/incomplete browser-looking User-Agents while allowlisting known IPTV player clients; a player-style User-Agent (currently a VLC signature) avoids diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 1bf16ec9f..3b6293254 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -88,6 +88,9 @@ describe('SerialDetailsComponent', () => { serverUrl: 'http://xtream.example', username: 'user', password: 'pass', + userAgent: 'Provider Player/1.0', + referrer: 'https://provider.test/player', + origin: 'https://provider.test', }); const fetchSerialDetailsWithMetadata = jest.fn(); const cancelDetailsRequest = jest.fn(); @@ -356,6 +359,14 @@ describe('SerialDetailsComponent', () => { ], }); expect(seasonContainer?.downloadsEnabled()).toBe(true); + expect(seasonContainer?.xtreamDownloadContext()).toEqual({ + serverUrl: 'http://xtream.example', + username: 'user', + password: 'pass', + userAgent: 'Provider Player/1.0', + referrer: 'https://provider.test/player', + origin: 'https://provider.test', + }); expect(seasonContainer?.downloadMetadataContext()).toEqual( expect.objectContaining({ language: 'en', diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 19b98a87a..9c45117a1 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -260,6 +260,9 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { serverUrl: playlist.serverUrl, username: playlist.username, password: playlist.password, + userAgent: playlist.userAgent, + referrer: playlist.referrer, + origin: playlist.origin, } : null ); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts index b7fc6a8a1..720006b3b 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts @@ -5,6 +5,7 @@ import { DownloadsService } from '@iptvnator/services'; import { resolveXtreamVodPlaybackSource } from '@iptvnator/portal/xtream/data-access'; import { getXtreamVodInfo, + XTREAM_CLIENT_USER_AGENT, XtreamVodDetails, type XtreamVodInfo, } from '@iptvnator/shared/interfaces'; @@ -179,7 +180,8 @@ export class VodDetailsDownloadsService { : textList(info?.director)?.map((name) => ({ name })), }), headers: { - userAgent: playlist.userAgent, + userAgent: + playlist.userAgent?.trim() || XTREAM_CLIENT_USER_AGENT, referer: playlist.referrer, origin: playlist.origin, }, diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts index fbc5bfe85..130bdf0dc 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts @@ -462,6 +462,9 @@ describe('VodDetailsRouteComponent — playback actions', () => { // came from — so the download would fetch the wrong movie. expect(stubs.startDownload).toHaveBeenCalledWith( expect.objectContaining({ + headers: expect.objectContaining({ + userAgent: 'VLC/3.0.18 LibVLC/3.0.18', + }), playlistId: 'playlist-1', xtreamId: 650020, metadataSnapshot: expect.objectContaining({ diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts index 1e89f598a..9987eaccd 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts @@ -399,6 +399,11 @@ describe('VodDetailsRouteComponent fallback actions', () => { ); expect(startDownload).toHaveBeenCalledWith( expect.objectContaining({ + headers: { + origin: 'https://origin.example', + referer: 'https://referrer.example', + userAgent: 'IPTVnator', + }, title: 'Catalog movie', posterUrl: 'https://example.com/catalog-poster.jpg', url: 'http://example.com/movie/650020.mp4', diff --git a/libs/shared/interfaces/src/lib/xtream-portal.utils.ts b/libs/shared/interfaces/src/lib/xtream-portal.utils.ts index afb76e303..3eaf73a67 100644 --- a/libs/shared/interfaces/src/lib/xtream-portal.utils.ts +++ b/libs/shared/interfaces/src/lib/xtream-portal.utils.ts @@ -1,8 +1,5 @@ export type XtreamPortalStatusType = - | 'active' - | 'inactive' - | 'expired' - | 'unavailable'; + 'active' | 'inactive' | 'expired' | 'unavailable'; export interface XtreamPortalStatusResponseLike { user_info?: { @@ -19,6 +16,10 @@ export interface XtreamCredentialsFromUrl { const XTREAM_API_ENDPOINT_PATTERN = /\/(?:get|player_api)\.php$/i; +// Some Xtream panels challenge generic Node HTTP clients while allowing +// established IPTV players. Keep API, probe, and download requests aligned. +export const XTREAM_CLIENT_USER_AGENT = 'VLC/3.0.18 LibVLC/3.0.18'; + export function normalizeXtreamServerUrl(value: string): string { const trimmed = value.trim(); if (!trimmed) { diff --git a/libs/ui/components/src/lib/season-container/episode-download.util.ts b/libs/ui/components/src/lib/season-container/episode-download.util.ts index de013e077..36960d811 100644 --- a/libs/ui/components/src/lib/season-container/episode-download.util.ts +++ b/libs/ui/components/src/lib/season-container/episode-download.util.ts @@ -1,5 +1,6 @@ import { DownloadMetadataSnapshot, + XTREAM_CLIENT_USER_AGENT, XtreamSerieEpisode, XtreamSerieEpisodeInfo, } from '@iptvnator/shared/interfaces'; @@ -19,6 +20,9 @@ export interface XtreamEpisodeDownloadContext { serverUrl?: string; username?: string; password?: string; + userAgent?: string; + referrer?: string; + origin?: string; } /** Provider-neutral metadata already loaded by the detail host. */ @@ -34,6 +38,11 @@ export interface XtreamEpisodeDownloadRequest { seriesXtreamId: number; seasonNumber: number; episodeNumber: number; + headers: { + userAgent: string; + referer?: string; + origin?: string; + }; metadataSnapshot?: DownloadMetadataSnapshot; } @@ -150,6 +159,11 @@ export function buildXtreamEpisodeDownloadRequest(options: { seriesXtreamId: seriesId, seasonNumber, episodeNumber, + headers: { + userAgent: context.userAgent?.trim() || XTREAM_CLIENT_USER_AGENT, + referer: context.referrer, + origin: context.origin, + }, ...(options.metadataContext ? { metadataSnapshot: buildMetadataSnapshot( diff --git a/libs/ui/components/src/lib/season-container/episode-utils.spec.ts b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts index c363f2852..a104c5928 100644 --- a/libs/ui/components/src/lib/season-container/episode-utils.spec.ts +++ b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts @@ -1,4 +1,7 @@ -import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import { + XTREAM_CLIENT_USER_AGENT, + XtreamSerieEpisode, +} from '@iptvnator/shared/interfaces'; import { buildXtreamEpisodeDownloadRequest, getEpisodeDownloadId, @@ -76,6 +79,35 @@ describe('episode-download.util', () => { seriesXtreamId: 900, seasonNumber: 2, episodeNumber: 3, + headers: { + userAgent: XTREAM_CLIENT_USER_AGENT, + referer: undefined, + origin: undefined, + }, + }); + }); + + it('forwards playlist request headers for the first episode transfer', () => { + const request = buildXtreamEpisodeDownloadRequest({ + episode: episode({ id: '55' }), + context: { + serverUrl: 'http://host', + username: 'u', + password: 'p', + userAgent: 'Provider Player/1.0', + referrer: 'https://provider.test/player', + origin: 'https://provider.test', + }, + playlistId: 'pl-1', + seriesId: 900, + seriesTitle: 'Show', + fallbackSeasonKey: '1', + }); + + expect(request.headers).toEqual({ + userAgent: 'Provider Player/1.0', + referer: 'https://provider.test/player', + origin: 'https://provider.test', }); }); diff --git a/libs/ui/components/src/lib/season-container/season-container.component.spec.ts b/libs/ui/components/src/lib/season-container/season-container.component.spec.ts index 0fc992b13..d56b4f2f3 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.spec.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.spec.ts @@ -157,6 +157,9 @@ describe('SeasonContainerComponent', () => { serverUrl: 'http://host', username: 'u', password: 'p', + userAgent: 'Provider Player/1.0', + referrer: 'https://provider.test/player', + origin: 'https://provider.test', }); fixture.componentRef.setInput('downloadMetadataContext', { language: 'en', @@ -170,6 +173,11 @@ describe('SeasonContainerComponent', () => { expect(downloadsServiceStub.startDownload).toHaveBeenCalledWith( expect.objectContaining({ title: 'Signal House - S01E01 - Pilot', + headers: { + userAgent: 'Provider Player/1.0', + referer: 'https://provider.test/player', + origin: 'https://provider.test', + }, metadataSnapshot: expect.objectContaining({ mediaKind: 'series', title: 'Signal House', diff --git a/libs/ui/components/src/lib/season-container/season-container.component.ts b/libs/ui/components/src/lib/season-container/season-container.component.ts index 483d9da2b..d4ba3ca77 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.ts @@ -36,6 +36,7 @@ import { getEpisodeDownloadId, isStalkerEpisode, type SeasonContainerDownloadMetadataContext, + type XtreamEpisodeDownloadContext, } from './episode-download.util'; import { EPISODE_INFO_PLAY, @@ -51,11 +52,7 @@ import { SeasonTabsComponent } from './season-tabs.component'; type EpisodeViewMode = 'grid' | 'list'; const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode'; -export interface SeasonContainerXtreamDownloadContext { - serverUrl?: string; - username?: string; - password?: string; -} +export type SeasonContainerXtreamDownloadContext = XtreamEpisodeDownloadContext; export type { SeasonContainerDownloadMetadataContext } from './episode-download.util'; From 04b2f9b82e34da6f72cf857bc6ad43a87b1b40df Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:02:08 +0200 Subject: [PATCH 06/18] test(e2e): pin the phone-layout invariants from #1326 (#1333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #1326 fixed the workspace on phone-sized screens (issue #1100) with SCSS-only changes and no automated coverage. This adds a mobile-layout smoke spec asserting the invariants that regressed before: no horizontal overflow on dashboard/Xtream/settings, rail links inside the 52px top bar, the context panel stacking above full-width content on portal routes, the settings section list ending above the Back footer, and the 640x360 landscape live route keeping the channel sidebar >= 72px with the player container inside the viewport. The Xtream tests import the portal at desktop width and then shrink the viewport, so the persisted inline rail widths from ResizableDirective — the exact #1100 regression scenario — are present when the phone rules must win. Run: pnpm nx run web-e2e:e2e-ci--src/mobile-layout.e2e.ts Co-authored-by: Claude Fable 5 --- apps/web-e2e/src/mobile-layout.e2e.ts | 224 ++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 apps/web-e2e/src/mobile-layout.e2e.ts diff --git a/apps/web-e2e/src/mobile-layout.e2e.ts b/apps/web-e2e/src/mobile-layout.e2e.ts new file mode 100644 index 000000000..ff27a965f --- /dev/null +++ b/apps/web-e2e/src/mobile-layout.e2e.ts @@ -0,0 +1,224 @@ +import type { Locator, Page } from '@playwright/test'; +import { expect, test } from './fixtures'; +import { + addXtreamPortal, + interceptPwaProviderRequests, + resetPwaMockServers, +} from './sources-pwa.helpers'; + +/** + * Mobile Layout Smoke Tests + * + * PR #1326 made the workspace usable on phone-sized screens (issue #1100) + * with SCSS-only changes and no automated coverage. These tests pin the + * invariants that regressed before: + * + * 1. No horizontal overflow — document.documentElement.scrollWidth stays + * within the viewport on dashboard, Xtream VOD/live, and settings. + * 2. The workspace rail links render inside the 52px top bar instead of + * stacking downwards over the header. + * 3. On a portal route the context panel and the content stack: the + * content keeps roughly the full viewport width instead of the ~50px + * a persisted desktop panel width used to leave it. + * 4. The settings section list scrolls instead of painting over the + * Back footer. + * 5. On a 640x360 landscape phone the live route keeps the channel + * sidebar at least 72px tall and the player container inside the + * viewport. + * + * Conventions: docs/architecture/iptvnator-ui-guidelines.md, "Phone Layout" + * (640px breakpoint, resizable rails stack full-width via `!important`). + * + * Tag: @mobile — run only this spec with: + * pnpm nx run web-e2e:e2e-ci--src/mobile-layout.e2e.ts + */ + +const PHONE = { width: 375, height: 812 }; +const LANDSCAPE_PHONE = { width: 640, height: 360 }; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function expectNoHorizontalOverflow( + page: Page, + viewportWidth: number +): Promise { + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth)) + .toBeLessThanOrEqual(viewportWidth); +} + +async function boxOf( + locator: Locator +): Promise<{ x: number; y: number; width: number; height: number }> { + const box = await locator.boundingBox(); + expect(box, 'expected element to have a bounding box').not.toBeNull(); + return box as NonNullable; +} + +/** + * Before #1326 the nested `.rail-links` list kept `flex-direction: column` + * inside the horizontal phone bar, so links 2..n were laid out below the + * 52px row and overlapped the header. Horizontal overflow inside the bar + * is fine — the bar scrolls sideways by design — so only y is asserted. + */ +async function expectRailLinksInsideTopBar(page: Page): Promise { + const rail = page.locator('.app-rail'); + await expect(rail).toBeVisible(); + + const railBox = await boxOf(rail); + // The shell grid gives the rail row 52px on phones. + expect(railBox.height).toBeLessThanOrEqual(60); + + await expect(page.locator('.rail-links').first()).toHaveCSS( + 'flex-direction', + 'row' + ); + + const links = page.locator('.app-rail a:visible'); + const count = await links.count(); + expect(count).toBeGreaterThan(0); + + for (let i = 0; i < count; i++) { + const linkBox = await boxOf(links.nth(i)); + expect(linkBox.y).toBeGreaterThanOrEqual(railBox.y - 1); + expect(linkBox.y + linkBox.height).toBeLessThanOrEqual( + railBox.y + railBox.height + 1 + ); + } +} + +// --------------------------------------------------------------------------- +// Portrait phone — no imported sources needed, app boots at phone size +// --------------------------------------------------------------------------- + +test.describe('portrait phone 375x812', () => { + test.use({ viewport: PHONE }); + + test('@mobile dashboard fits the viewport and keeps rail links in the top bar', async ({ + page, + }) => { + await page.goto('/workspace/dashboard'); + await expect(page.locator('.app-rail')).toBeVisible(); + + await expectNoHorizontalOverflow(page, PHONE.width); + await expectRailLinksInsideTopBar(page); + }); + + test('@mobile settings section list stays clear of the Back footer', async ({ + page, + }) => { + await page.goto('/workspace/settings'); + + const panel = page.locator('.context-panel--settings'); + await expect(panel).toBeVisible(); + + // The panel stacks above the content at full width even though + // ResizableDirective writes its persisted desktop width inline. + const panelBox = await boxOf(panel); + expect(panelBox.width).toBeGreaterThanOrEqual(PHONE.width - 2); + + const footer = panel.locator('.settings-panel-footer'); + await expect(footer.locator('.settings-back-button')).toBeVisible(); + + // Before #1326 the section list kept its full content height and + // painted over the footer whenever the panel was shorter than its + // sections; now the list scrolls and ends above the footer. + const listBox = await boxOf(panel.locator('.settings-sections-list')); + const footerBox = await boxOf(footer); + expect(listBox.y + listBox.height).toBeLessThanOrEqual( + footerBox.y + 1 + ); + + await expectNoHorizontalOverflow(page, PHONE.width); + }); +}); + +// --------------------------------------------------------------------------- +// Xtream portal routes — the portal is imported at the default desktop +// viewport (which persists desktop rail widths, the exact regression +// scenario from #1100), then the window shrinks to phone size. +// --------------------------------------------------------------------------- + +test.describe('xtream portal routes on a phone', () => { + test.beforeEach(async ({ page, request }) => { + await resetPwaMockServers(request); + await interceptPwaProviderRequests(page); + await page.goto('/'); + await addXtreamPortal(page, 'Mobile Layout Portal'); + }); + + test('@mobile @xtream vod route stacks the context panel above full-width content', async ({ + page, + }) => { + await page.setViewportSize(PHONE); + + const panel = page.locator('.context-panel'); + await expect(panel).toBeVisible(); + + // The persisted desktop width is written as an inline style; the + // phone rule must win with `width: 100% !important`, otherwise the + // panel keeps ~300px of a 375px screen. + await expect + .poll(async () => (await panel.boundingBox())?.width ?? 0) + .toBeGreaterThanOrEqual(PHONE.width - 2); + + const content = page.locator('main.workspace-content'); + const contentWidth = await content.evaluate((el) => el.clientWidth); + expect(contentWidth).toBeGreaterThanOrEqual(PHONE.width - 20); + + // Stacked, not side by side: the content starts below the panel. + const panelBox = await boxOf(panel); + const contentBox = await boxOf(content); + expect(contentBox.y).toBeGreaterThanOrEqual( + panelBox.y + panelBox.height - 1 + ); + + await expectNoHorizontalOverflow(page, PHONE.width); + await expectRailLinksInsideTopBar(page); + }); + + test('@mobile @xtream landscape live route keeps the channel list and the player inside the viewport', async ({ + page, + }) => { + // Select a category at desktop width so the channel sidebar renders + // (`showLiveChannelSidebar` requires a selected category). On the + // live root the click updates store state without navigating, so the + // sidebar appearing is the completion signal — not a URL change. + await page.goto(page.url().replace(/\/vod.*$/, '/live')); + const firstCategory = page + .locator('.context-panel .category-item') + .first(); + await expect(firstCategory).toBeVisible(); + await firstCategory.click(); + + const sidebar = page.locator('app-live-stream-layout .sidebar'); + await expect(sidebar).toBeVisible(); + + await page.setViewportSize(LANDSCAPE_PHONE); + + // The sidebar stacks at full width above the player. + await expect + .poll(async () => (await sidebar.boundingBox())?.width ?? 0) + .toBeGreaterThanOrEqual(LANDSCAPE_PHONE.width - 2); + + // Before the follow-up fix in #1326 the player's 240px floor drove + // the still-expanded sidebar to zero height on a 640x360 screen, + // leaving no way to pick another channel. + const sidebarBox = await boxOf(sidebar); + expect(sidebarBox.height).toBeGreaterThanOrEqual(71); + + // ...and pushed the layout past the viewport. The player container + // has to end inside it. + const contentBox = await boxOf( + page.locator('app-live-stream-layout .content-container') + ); + expect(contentBox.y + contentBox.height).toBeLessThanOrEqual( + LANDSCAPE_PHONE.height + 1 + ); + expect(contentBox.width).toBeLessThanOrEqual(LANDSCAPE_PHONE.width); + + await expectNoHorizontalOverflow(page, LANDSCAPE_PHONE.width); + }); +}); From 297e9fbef83a336a84ae75a027a3b398362177a0 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:38:11 +0200 Subject: [PATCH 07/18] fix(stalker): send cmd in the reference MAG wire format (#1334) * fix(stalker): send cmd in the reference MAG wire format A real MAG sends cmd unencoded and the portal decodes its query exactly once, so a cmd that already contains percent sequences (%3A tokens, pre-encoded path segments) must pass through untouched. The previous encodeURIComponent transport (2c032cd3c, 0.22) double-encoded such cmds (%3A -> %253A): strict portals and reseller panels that compare cmd literally, and stock create_link handlers matching the decoded value, saw a different string than a real STB sends. The new shared encodeStalkerCmdValue() reproduces the reference wire bytes: % passes through verbatim, characters the WHATWG URL serializer keeps raw in a query stay raw (so the bytes survive the axios/new URL transport unchanged), and everything else is percent-encoded. That preserves the 0.22 injection protection - &, # (and ; for PHP setups with a ; argument separator) inside cmd cannot append or truncate query parameters; they decode back to the original byte server-side. Both transports now share the format: the Electron query builder is extracted to buildStalkerRequestUrl() and the web-backend /stalker proxy appends cmd to the portal URL itself instead of letting axios turn slashes into %2F (the opposite divergence). Also unifies the two divergent response-side cmd normalizers: the cross-portal collection resolver now uses the Stalker store's normalizeStalkerPlaybackCommand/resolveStalkerPlaybackUrl, so playing from Favorites/global collections resolves relative (/media/...) and query-only (?token=...) create_link replies against the portal base instead of handing the player a bare relative path. The mock portal's create_link response gains mock-only cmd_received/ query_keys_received diagnostics; a new Electron e2e pins the contract end-to-end (single decode, injection blocked). Unit corpus tests cover the encoder, the Electron builder, the web-backend proxy, and the resolver. Co-Authored-By: Claude Fable 5 * fix(pwa): sanitize portal URL before appending stalker cmd A registered portal URL carrying a fragment would swallow the appended cmd (everything after # is never transmitted), and a trailing bare '?' produced '??cmd='. Drop the hash and pick the separator from the sanitized href before appending. Flagged by Greptile/Codex on #1334. Co-Authored-By: Claude Fable 5 * refactor(pwa): make stalker cmd append visibly query-only for CodeQL Rebuild the /stalker request URL through the URL object and concatenate the encoded cmd strictly behind a literal '?', so static analysis can see the tainted value never reaches host or path (js/request-forgery alert on the previous separator ternary). Behavior unchanged; the fragment/bare-'?' regression tests still pin the wire format. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .changes/stalker-cmd-encoding.md | 10 ++ .../electron-backend-e2e/src/providers.e2e.ts | 56 ++++++++ .../app/events/stalker-request-url.spec.ts | 136 ++++++++++++++++++ .../src/app/events/stalker-request-url.ts | 41 ++++++ .../src/app/events/stalker.events.ts | 34 +---- .../src/app/handlers/create-link.handler.ts | 7 + .../src/app/web-backend-app.spec.ts | 88 ++++++++++++ apps/web-backend/src/app/web-backend-app.ts | 34 ++++- docs/architecture/stalker-mock-server.md | 10 +- docs/architecture/stalker-portal.md | 45 ++++++ .../stream-resolver.service.spec.ts | 62 ++++++++ .../lib/collection/stream-resolver.service.ts | 39 +++-- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/stalker-cmd-encoding.util.spec.ts | 94 ++++++++++++ .../src/lib/stalker-cmd-encoding.util.ts | 55 +++++++ 15 files changed, 653 insertions(+), 59 deletions(-) create mode 100644 .changes/stalker-cmd-encoding.md create mode 100644 apps/electron-backend/src/app/events/stalker-request-url.spec.ts create mode 100644 apps/electron-backend/src/app/events/stalker-request-url.ts create mode 100644 libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.ts diff --git a/.changes/stalker-cmd-encoding.md b/.changes/stalker-cmd-encoding.md new file mode 100644 index 000000000..d265b0b0f --- /dev/null +++ b/.changes/stalker-cmd-encoding.md @@ -0,0 +1,10 @@ +--- +type: fix +area: stalker +--- + +Stalker portals now receive channel commands exactly as a real set-top box +sends them: already-encoded parts of a channel's `cmd` are no longer +double-encoded, so strict portals and reseller panels that compare the command +literally work again. Playing Stalker channels from Favorites or global +collections now also handles portals that answer with relative stream paths. diff --git a/apps/electron-backend-e2e/src/providers.e2e.ts b/apps/electron-backend-e2e/src/providers.e2e.ts index 0ff51b833..2fb590abc 100644 --- a/apps/electron-backend-e2e/src/providers.e2e.ts +++ b/apps/electron-backend-e2e/src/providers.e2e.ts @@ -4,6 +4,7 @@ import { addStalkerPortal, addXtreamPortal, closeElectronApp, + defaultStalkerMacAddress, defaultStalkerPortalName, defaultXtreamPortalName, expect, @@ -67,6 +68,61 @@ test.describe('Electron Provider Smoke Tests', () => { } }); + test('@stalker @electron delivers cmd to the portal decoded exactly once with query injection blocked', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['stalker']); + + const app = await launchElectronApp(dataDir); + + try { + // Stored cmd with a pre-encoded token (%3A), a literal '+', and a + // query-injection attempt (&injected=1#frag). + const storedCmd = + 'ffrt3 http://example.com/ch/123?token=a%3Ab+c&injected=1#frag'; + + const response = await app.mainWindow.evaluate( + async ({ url, macAddress, cmd }) => + window.electron.stalkerRequest({ + url, + macAddress, + params: { action: 'create_link', type: 'itv', cmd }, + }), + { + url: `${stalkerMockServer}/portal.php`, + macAddress: defaultStalkerMacAddress, + cmd: storedCmd, + } + ); + + const js = ( + response as { + js: { cmd_received: string; query_keys_received: string[] }; + } + ).js; + + // The portal must see the stored cmd decoded exactly once — + // %3A → ':', '+' → space — the same view it gets from a real STB. + // The old encodeURIComponent transport double-encoded '%' and + // delivered the %3A/+ sequences still encoded. + expect(js.cmd_received).toBe( + 'ffrt3 http://example.com/ch/123?token=a:b c&injected=1#frag' + ); + + // The '&'/'#' inside cmd stayed inside the cmd value instead of + // restructuring the portal query. + expect(js.query_keys_received).toEqual([ + 'JsHttpRequest', + 'action', + 'cmd', + 'type', + ]); + } finally { + await closeElectronApp(app); + } + }); + test('@xtream @electron shows refresh overlay immediately from the dashboard Xtream source menu', async ({ dataDir, request, diff --git a/apps/electron-backend/src/app/events/stalker-request-url.spec.ts b/apps/electron-backend/src/app/events/stalker-request-url.spec.ts new file mode 100644 index 000000000..bb5292cc9 --- /dev/null +++ b/apps/electron-backend/src/app/events/stalker-request-url.spec.ts @@ -0,0 +1,136 @@ +import { buildStalkerRequestUrl } from './stalker-request-url'; + +const PORTAL = 'http://portal.example/stalker_portal/server/load.php'; + +/** Decode a query value the way PHP's `$_GET` would (one form-urldecode). */ +function portalVisibleValue(encoded: string): string { + return decodeURIComponent(encoded.replace(/\+/g, '%20')); +} + +function cmdWireValue(fullUrl: string): string { + const match = /[?&]cmd=([^&]*)/.exec(fullUrl); + if (!match) { + throw new Error(`no cmd param in ${fullUrl}`); + } + return match[1]; +} + +describe('buildStalkerRequestUrl', () => { + it('builds the reference wire format for a typical create_link request', () => { + const fullUrl = buildStalkerRequestUrl(PORTAL, { + type: 'itv', + action: 'create_link', + cmd: 'ffrt3 http://host/ch/123', + }); + + expect(fullUrl).toBe( + 'http://portal.example/stalker_portal/server/load.php' + + '?type=itv&action=create_link' + + '&cmd=ffrt3%20http://host/ch/123&JsHttpRequest=1-xml' + ); + }); + + it.each([ + ['ffrt3 http://host/ch/123', 'ffrt3%20http://host/ch/123'], + [ + 'auto http://host/ch/123?token=abc', + 'auto%20http://host/ch/123?token=abc', + ], + ['/media/12345.mpg', '/media/12345.mpg'], + [ + 'auto http://host/s/a%3Ab%20c.m3u8', + 'auto%20http://host/s/a%3Ab%20c.m3u8', + ], + ])('sends cmd %s as %s', (cmd, expectedWireValue) => { + const fullUrl = buildStalkerRequestUrl(PORTAL, { + action: 'create_link', + cmd, + }); + + expect(cmdWireValue(fullUrl)).toBe(expectedWireValue); + }); + + it('does not double-encode a cmd that already contains percent sequences', () => { + const fullUrl = buildStalkerRequestUrl(PORTAL, { + action: 'create_link', + cmd: 'auto http://host/s/a%3Ab.m3u8?sig=x%2Fy', + }); + + expect(fullUrl).not.toContain('%25'); + // After the portal's single decode, pre-encoded sequences resolve — + // exactly what it would receive from a real STB. + expect(portalVisibleValue(cmdWireValue(fullUrl))).toBe( + 'auto http://host/s/a:b.m3u8?sig=x/y' + ); + }); + + it('blocks query-parameter injection through cmd without losing data', () => { + const maliciousCmd = + 'http://host/ch/1?x=1&action=do_evil&mac=00:00:00:00:00:00#frag'; + const fullUrl = buildStalkerRequestUrl(PORTAL, { + action: 'create_link', + type: 'itv', + cmd: maliciousCmd, + }); + + const params = new URL(fullUrl).searchParams; + expect(params.getAll('action')).toEqual(['create_link']); + expect(params.get('mac')).toBeNull(); + expect(params.get('x')).toBeNull(); + expect(fullUrl).not.toContain('#'); + // The dangerous characters are escaped, not stripped: the portal + // still receives the full original cmd string after one decode. + expect(portalVisibleValue(cmdWireValue(fullUrl))).toBe(maliciousCmd); + }); + + it('keeps full encoding for non-cmd params', () => { + const fullUrl = buildStalkerRequestUrl(PORTAL, { + action: 'get_profile', + metrics: '{"mac":"00:1A:79:AA:BB:CC"}', + }); + + expect(fullUrl).toContain( + 'metrics=%7B%22mac%22%3A%2200%3A1A%3A79%3AAA%3ABB%3ACC%22%7D' + ); + }); + + it('appends JsHttpRequest only when missing', () => { + const withoutIt = buildStalkerRequestUrl(PORTAL, { action: 'x' }); + expect(withoutIt.match(/JsHttpRequest/g)).toHaveLength(1); + + const withIt = buildStalkerRequestUrl(PORTAL, { + action: 'x', + JsHttpRequest: '1-xml', + }); + expect(withIt.match(/JsHttpRequest/g)).toHaveLength(1); + }); + + it('drops any query string carried by the portal URL itself', () => { + const fullUrl = buildStalkerRequestUrl( + 'http://portal.example/portal.php?stale=1', + { action: 'handshake' } + ); + + expect(fullUrl).toBe( + 'http://portal.example/portal.php?action=handshake&JsHttpRequest=1-xml' + ); + }); + + it('emits URLs whose bytes survive WHATWG re-parsing (the axios transport)', () => { + const cmds = [ + 'ffrt3 http://host/ch/123', + 'auto http://host/ch/123?token=a%3Ab c', + '/media/12345.mpg', + 'x&y=z#w;v', + 'auto http://host/канал/1', + ]; + + for (const cmd of cmds) { + const fullUrl = buildStalkerRequestUrl(PORTAL, { + action: 'create_link', + cmd, + }); + expect(new URL(fullUrl).toString()).toBe(fullUrl); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/stalker-request-url.ts b/apps/electron-backend/src/app/events/stalker-request-url.ts new file mode 100644 index 000000000..1e5226027 --- /dev/null +++ b/apps/electron-backend/src/app/events/stalker-request-url.ts @@ -0,0 +1,41 @@ +import { encodeStalkerCmdValue } from '@iptvnator/shared/interfaces'; + +/** + * Builds the full Stalker portal request URL from an already-validated portal + * URL and the prepared request params. + * + * The query string is assembled manually because the two parameter classes + * need different encodings: + * + * - `cmd` uses the minimal reference encoding (`encodeStalkerCmdValue`): a + * real MAG sends cmd unencoded and the portal decodes the query exactly + * once, so pre-encoded sequences (`%3A`) must pass through untouched while + * `&`/`#`/`;` are still escaped so a malicious portal cannot append query + * parameters through cmd. + * - every other param is fully `encodeURIComponent`-encoded. + * + * Any query string on the portal URL itself is intentionally dropped (the + * request params are the complete query), matching long-standing behavior. + */ +export function buildStalkerRequestUrl( + url: string, + requestParams: Record +): string { + const urlObject = new URL(url); + const queryParts: string[] = []; + + Object.entries(requestParams).forEach(([key, value]) => { + if (key === 'cmd') { + queryParts.push(`${key}=${encodeStalkerCmdValue(String(value))}`); + } else { + queryParts.push(`${key}=${encodeURIComponent(String(value))}`); + } + }); + + // Always add JsHttpRequest parameter if not present (required by Stalker API) + if (!requestParams['JsHttpRequest']) { + queryParts.push('JsHttpRequest=1-xml'); + } + + return `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`; +} diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index a46a039f5..ccd31ae31 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -13,6 +13,7 @@ import { redactSensitiveData } from '@iptvnator/shared/logging'; import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service'; import { emitPortalDebugEvent } from './portal-debug.events'; import { buildStalkerIdentityRequestContext } from './stalker-identity'; +import { buildStalkerRequestUrl } from './stalker-request-url'; import { assertRemoteUrlAllowed } from './url-safety'; import { requestWithValidatedRedirects } from '../util/validated-axios'; @@ -58,42 +59,11 @@ ipcMain.handle( serialNumber, }); - // Build URL with query parameters - // Note: For 'cmd' parameter, we need to use encodeURI (not encodeURIComponent) - // to preserve forward slashes, matching stalker-to-m3u implementation // SSRF/LFI guard: block non-http(s)/credentialed portal URLs. // Private/LAN targets remain allowed (users run local Stalker servers). await assertRemoteUrlAllowed(url, { allowPrivateNetworks: true }); - const urlObject = new URL(url); - const queryParts: string[] = []; - Object.entries(requestParams).forEach(([key, value]) => { - if (key === 'cmd') { - // Encode cmd but preserve forward slashes so the path format - // (e.g. /media/12345.mpg) the server expects still survives. - // Encoding the remaining characters prevents a malicious portal - // from injecting extra query parameters (&, =, #) into the URL. - queryParts.push( - `${key}=${encodeURIComponent(String(value)).replace( - /%2F/gi, - '/' - )}` - ); - } else { - // Use encodeURIComponent for other params - queryParts.push( - `${key}=${encodeURIComponent(String(value))}` - ); - } - }); - - // Always add JsHttpRequest parameter if not present (required by Stalker API) - if (!requestParams['JsHttpRequest']) { - queryParts.push('JsHttpRequest=1-xml'); - } - - // Build final URL with manually constructed query string - const fullUrl = `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`; + const fullUrl = buildStalkerRequestUrl(url, requestParams); // Determine timeout based on action type // create_link requests can take longer as server generates stream URL diff --git a/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts index e3a35d6d2..58bf7f5a4 100644 --- a/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts @@ -28,6 +28,13 @@ export function handleCreateLink(req: Request, res: Response): void { streamer_id: '1', load: '', error: '', + // Mock-only diagnostics (absent from real portal responses): what + // this request actually delivered after Express' single query + // decode — the same view a PHP portal gets from $_GET. E2E uses + // them to pin the cmd wire contract (no double-encoding, no + // query-parameter injection through cmd). + cmd_received: cmd, + query_keys_received: Object.keys(req.query).sort(), }, }); } diff --git a/apps/web-backend/src/app/web-backend-app.spec.ts b/apps/web-backend/src/app/web-backend-app.spec.ts index c44eaa147..b1448144f 100644 --- a/apps/web-backend/src/app/web-backend-app.spec.ts +++ b/apps/web-backend/src/app/web-backend-app.spec.ts @@ -390,6 +390,94 @@ https://stream.example/live.m3u8`); ); }); + it('forwards Stalker cmd in the reference wire format instead of axios encoding', async () => { + const httpClient = new StubHttpClient(); + httpClient.queueResponse({ js: { cmd: 'http://cdn/stream.m3u8' } }); + + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://stalker.example/portal.php' + ); + // The PWA renderer sends fully URLSearchParams-encoded values; + // Express decodes them back to the stored cmd string. + const query = new URLSearchParams({ + targetId, + macAddress: '00:1A:79:00:00:01', + action: 'create_link', + type: 'itv', + cmd: 'ffrt3 http://host/ch/123?token=a%3Ab c&x=1', + }); + const response = await fetch( + `${baseUrl}/stalker?${query.toString()}` + ); + await response.json(); + + // Slashes stay raw and pre-encoded sequences pass through + // untouched (no %25 double-encoding); '&' inside cmd cannot + // append query parameters. cmd is no longer in axios params. + expect(httpClient.requests).toEqual([ + { + headers: { + Cookie: 'mac=00:1A:79:00:00:01', + }, + params: { + action: 'create_link', + macAddress: '00:1A:79:00:00:01', + type: 'itv', + }, + url: + 'http://stalker.example/portal.php' + + '?cmd=ffrt3%20http://host/ch/123?token=a%3Ab%20c%26x=1', + }, + ]); + } + ); + }); + + it('keeps cmd in the query when the registered portal URL carries a fragment or bare ?', async () => { + const httpClient = new StubHttpClient(); + httpClient.queueResponse({ js: { cmd: 'http://cdn/a.m3u8' } }); + httpClient.queueResponse({ js: { cmd: 'http://cdn/b.m3u8' } }); + + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + // A fragment on the registered URL must not swallow the + // appended cmd (fragments are never sent to the portal). + const fragmentTarget = await registerProviderTarget( + baseUrl, + 'http://stalker.example/portal.php#legacy' + ); + await fetch( + `${baseUrl}/stalker?targetId=${fragmentTarget}&action=create_link&cmd=${encodeURIComponent('/media/1.mpg')}` + ); + + // A trailing bare '?' must not produce '??cmd='. + const bareQueryTarget = await registerProviderTarget( + baseUrl, + 'http://stalker.example/load.php?' + ); + await fetch( + `${baseUrl}/stalker?targetId=${bareQueryTarget}&action=create_link&cmd=${encodeURIComponent('/media/2.mpg')}` + ); + + expect(httpClient.requests.map((request) => request.url)).toEqual([ + 'http://stalker.example/portal.php?cmd=/media/1.mpg', + 'http://stalker.example/load.php?cmd=/media/2.mpg', + ]); + } + ); + }); + it('normalizes provider errors for portal proxy calls', async () => { const httpClient = new StubHttpClient(); httpClient.queueFailure(403, 'Forbidden'); diff --git a/apps/web-backend/src/app/web-backend-app.ts b/apps/web-backend/src/app/web-backend-app.ts index 1ccbba9c9..3acaf836e 100644 --- a/apps/web-backend/src/app/web-backend-app.ts +++ b/apps/web-backend/src/app/web-backend-app.ts @@ -7,7 +7,10 @@ import zlib from 'node:zlib'; import axios from 'axios'; import epgParser from 'epg-parser'; import parser from 'iptv-playlist-parser'; -import { normalizeXtreamServerUrl } from '@iptvnator/shared/interfaces'; +import { + encodeStalkerCmdValue, + normalizeXtreamServerUrl, +} from '@iptvnator/shared/interfaces'; import { extractDrmFromRaw } from '@iptvnator/shared/m3u-utils'; export interface WebBackendHttpGetOptions { @@ -225,10 +228,35 @@ export function createWebBackendApp( } try { + // `cmd` must reach the portal in the reference wire format (raw + // slashes, pre-encoded sequences untouched) — axios' default + // serializer would fully percent-encode it, diverging from what a + // real STB (and the Electron transport) sends. Append it to the + // URL with the shared encoder and let axios serialize the rest. + // The fragment is dropped first: a registered URL carrying `#...` + // would otherwise swallow the appended cmd, and a trailing bare + // `?` must not become `??cmd=`. + const { cmd, ...proxyParams } = getProxyParams(req, ['targetId']); + const portalUrl = new URL(url.href); + portalUrl.hash = ''; + const registeredQuery = portalUrl.search.replace(/^\?/, ''); + portalUrl.search = ''; + const query = [ + registeredQuery, + cmd ? `cmd=${encodeStalkerCmdValue(cmd)}` : '', + ] + .filter(Boolean) + .join('&'); + // cmd is appended strictly behind the literal `?`, so it can only + // ever form query content — never host or path. + const requestUrl = query + ? `${portalUrl.href}?${query}` + : portalUrl.href; + // Provider URLs are validated by /provider-targets before they enter the registry. // codeql[js/request-forgery] - const response = await httpClient.get(url.href, { - params: getProxyParams(req, ['targetId']), + const response = await httpClient.get(requestUrl, { + params: proxyParams, headers: { ...(macAddress ? { Cookie: `mac=${macAddress}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index 19b4d93df..0e58ebfc1 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -208,13 +208,21 @@ marker and an `ffrt4://radio/...` command. "cmd": "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8", "streamer_id": "1", "load": "", - "error": "" + "error": "", + "cmd_received": "ffrt4://ch/live/1001/index.m3u8", + "query_keys_received": ["JsHttpRequest", "action", "cmd", "type"] } } ``` The stream URL is selected from a pool of 4 real public HLS test streams. The choice is deterministic based on the `cmd` field's character sum, so the same item always returns the same stream. +`cmd_received` and `query_keys_received` are mock-only diagnostics (a real +portal does not send them): they echo the request's `cmd` after Express' single +query decode — the same view a PHP portal gets from `$_GET` — plus the sorted +set of query keys. E2E uses them to pin the client's `cmd` wire contract: no +double-encoding, and no query-parameter injection through `cmd`. + ### `get_short_epg` ```json diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 9c766986c..933a41581 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -138,6 +138,51 @@ blank fields are not generated or forwarded to `get_profile`. metadata is independent from M3U playlist EPG metadata and must not depend on M3U-specific EPG fields. +## Request Transport and `cmd` Encoding + +A real MAG/STB sends `cmd` unencoded: the portal's client JS concatenates raw +`key=value` pairs, the browser URL layer escapes only what a URL cannot carry, +and PHP's `$_GET` applies exactly one form-urldecode. The portal therefore sees +the stored `cmd` decoded **once** — a pre-encoded `%3A` arrives as `:` and a +literal `+` arrives as a space. IPTVnator reproduces that reference wire format +on both transports with the shared `encodeStalkerCmdValue()` +(`libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.ts`): + +- `%` passes through untouched, so a `cmd` that already contains percent + sequences is never double-encoded (the pre-0.23 `encodeURIComponent` + transport delivered `%253A` and strict panels no longer matched the string). +- Characters the WHATWG URL serializer keeps raw in a query stay raw + (`/ : ? = + , @ $ [ ]` …), so the emitted bytes survive the axios/`new URL` + transport unchanged. +- Everything else is percent-encoded. This keeps the injection protection from + the 0.22 hardening: `&`, `#` (and `;` for PHP setups with a `;` argument + separator) inside `cmd` cannot append or truncate query parameters — they + decode back to the original byte server-side, so the portal-visible value is + unaffected. + +Consumers of the encoder: + +- Electron: `buildStalkerRequestUrl()` + (`apps/electron-backend/src/app/events/stalker-request-url.ts`) assembles the + portal query for `STALKER_REQUEST`; `cmd` uses the reference encoding, every + other param stays fully `encodeURIComponent`-encoded, and `JsHttpRequest=1-xml` + is appended when missing. +- PWA: the web-backend `/stalker` proxy appends `cmd` to the portal URL with + the same encoder instead of letting axios' serializer turn slashes into + `%2F`. The renderer→proxy leg uses `URLSearchParams`, which Express decodes + losslessly, so the stored `cmd` string reaches the proxy intact. + +The mock portal's `create_link` response carries mock-only `cmd_received` and +`query_keys_received` diagnostics so E2E can pin this contract +(`apps/electron-backend-e2e/src/providers.e2e.ts`). + +Response-side `cmd` normalization is also shared: both the Stalker store and +the cross-portal collection resolver (`StreamResolverService`) use +`normalizeStalkerPlaybackCommand()` / `resolveStalkerPlaybackUrl()` from +`libs/portal/stalker/data-access`, which strip the ` ` prefix and +resolve relative (`/media/...`) or query-only (`?token=...`) `create_link` +replies against the portal base URL. + ## Live TV and Radio The Stalker live route and radio route intentionally share diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index e477acb5a..cabef2bae 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -696,4 +696,66 @@ describe('StreamResolverService', () => { }) ); }); + + it('resolves relative Stalker create_link responses against the portal base', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + portalUrl: + 'https://stalker.example.com/stalker_portal/server/load.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: false, + } satisfies Partial) + ); + // Portals frequently answer create_link with a solution-prefixed + // relative path; the shared normalizer must resolve it instead of + // handing the bare path to the player (the old weak normalizer did). + dataService.sendIpcEvent.mockResolvedValue({ + js: { cmd: 'ffmpeg /media/file_123.mpg' }, + }); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::88', + name: 'Relative Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '88', + stalkerCmd: 'ffrt3 http://stalker.example.com/media/999.mpg', + } satisfies UnifiedCollectionItem); + + expect(playback.streamUrl).toBe( + 'https://stalker.example.com/stalker_portal/media/file_123.mpg' + ); + }); + + it('appends query-only Stalker create_link responses to the original cmd URL', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + portalUrl: 'https://stalker.example.com/portal.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: false, + } satisfies Partial) + ); + dataService.sendIpcEvent.mockResolvedValue({ + js: { cmd: '?token=xyz' }, + }); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::89', + name: 'Token Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '89', + stalkerCmd: 'auto http://cdn.example.com/live/89.m3u8', + } satisfies UnifiedCollectionItem); + + expect(playback.streamUrl).toBe( + 'http://cdn.example.com/live/89.m3u8?token=xyz' + ); + }); }); diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 57cf4d129..32ac6425d 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -17,7 +17,11 @@ import { XtreamApiService, XtreamUrlService, } from '@iptvnator/portal/xtream/data-access'; -import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access'; +import { + normalizeStalkerPlaybackCommand, + resolveStalkerPlaybackUrl, + StalkerSessionService, +} from '@iptvnator/portal/stalker/data-access'; import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; type PlaylistWithChannels = Playlist & { @@ -323,7 +327,9 @@ export class StreamResolverService { const portalUrl = item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? ''; const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? ''; - const normalizedCmd = this.normalizeStalkerCmd(item.stalkerCmd ?? ''); + const normalizedCmd = normalizeStalkerPlaybackCommand( + item.stalkerCmd ?? '' + ); if (item.radio === 'true' && this.isHttpUrl(normalizedCmd)) { return { streamUrl: normalizedCmd, @@ -362,7 +368,14 @@ export class StreamResolverService { const rawCmd = response?.js?.cmd ?? ''; return { - streamUrl: this.normalizeStalkerCmd(rawCmd), + // Shared normalizer from the Stalker store: strips the solution + // prefix and resolves relative `/media/...` or `?...` responses + // against the portal base instead of returning them verbatim. + streamUrl: resolveStalkerPlaybackUrl( + portalUrl, + item.stalkerCmd ?? '', + rawCmd + ), title: item.name, thumbnail: item.logo ?? null, isLive: item.radio === 'true' ? undefined : true, @@ -1083,26 +1096,6 @@ export class StreamResolverService { })); } - private normalizeStalkerCmd(value: string): string { - const trimmed = String(value ?? '').trim(); - if (!trimmed) { - return ''; - } - - const splitAt = trimmed.indexOf(' '); - if (splitAt > 0) { - const candidate = trimmed.slice(splitAt + 1).trim(); - if ( - candidate.startsWith('http://') || - candidate.startsWith('https://') - ) { - return candidate; - } - } - - return trimmed; - } - private isHttpUrl(value: string): boolean { return value.startsWith('http://') || value.startsWith('https://'); } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 83bbe81d4..597ab8443 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -37,6 +37,7 @@ export * from './lib/portal-playback.interface'; export * from './lib/random-id.util'; export * from './lib/security-policy-error.utils'; export * from './lib/settings.interface'; +export * from './lib/stalker-cmd-encoding.util'; export * from './lib/stalker-portal-actions.enum'; export * from './lib/store-keys.enum'; export * from './lib/stream-format.enum'; diff --git a/libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.spec.ts b/libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.spec.ts new file mode 100644 index 000000000..d455d59d9 --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.spec.ts @@ -0,0 +1,94 @@ +import { encodeStalkerCmdValue } from './stalker-cmd-encoding.util'; + +/** + * One application-x-www-form-urlencoded decode, the way PHP's `$_GET` (and + * Express' query parser) sees the value: `+` is a space, `%XX` decodes once. + */ +function portalVisibleValue(encoded: string): string { + return decodeURIComponent(encoded.replace(/\+/g, '%20')); +} + +describe('encodeStalkerCmdValue', () => { + it('keeps a plain solution-prefixed cmd readable (space only)', () => { + expect(encodeStalkerCmdValue('ffrt3 http://host/ch/123')).toBe( + 'ffrt3%20http://host/ch/123' + ); + }); + + it('keeps ?, = and : raw in a tokened cmd', () => { + expect( + encodeStalkerCmdValue('auto http://host/ch/123?token=abc') + ).toBe('auto%20http://host/ch/123?token=abc'); + }); + + it('leaves a bare media path untouched', () => { + expect(encodeStalkerCmdValue('/media/12345.mpg')).toBe( + '/media/12345.mpg' + ); + }); + + it('never double-encodes pre-encoded sequences', () => { + expect( + encodeStalkerCmdValue('auto http://host/s/a%3Ab%20c.m3u8') + ).toBe('auto%20http://host/s/a%3Ab%20c.m3u8'); + }); + + it('passes a bare, malformed % through untouched', () => { + expect(encodeStalkerCmdValue('/media/100%.mpg')).toBe( + '/media/100%.mpg' + ); + }); + + it('encodes the query-structure characters &, # and ;', () => { + expect(encodeStalkerCmdValue('a&b#c;d')).toBe('a%26b%23c%3Bd'); + }); + + it('keeps + raw so the portal sees a space, like it does for a real STB', () => { + const encoded = encodeStalkerCmdValue('auto http://host/s/a+b.ts'); + expect(encoded).toBe('auto%20http://host/s/a+b.ts'); + expect(portalVisibleValue(encoded)).toBe('auto http://host/s/a b.ts'); + }); + + it('keeps IPv6 literals byte-identical', () => { + expect(encodeStalkerCmdValue('ffmpeg http://[::1]:8080/ch/1')).toBe( + 'ffmpeg%20http://[::1]:8080/ch/1' + ); + }); + + it('encodes quotes, backslash and control characters', () => { + expect(encodeStalkerCmdValue(`a"b'c\\d\ne`)).toBe( + 'a%22b%27c%5Cd%0Ae' + ); + }); + + it('percent-encodes non-ASCII as UTF-8', () => { + expect(encodeStalkerCmdValue('auto http://host/канал/1')).toBe( + 'auto%20http://host/%D0%BA%D0%B0%D0%BD%D0%B0%D0%BB/1' + ); + }); + + it('survives WHATWG URL re-parsing byte-identically', () => { + const corpus = [ + 'ffrt3 http://host/ch/123', + 'auto http://host/ch/123?token=abc', + '/media/12345.mpg', + 'auto http://host/s/a%3Ab%20c.m3u8', + "a&b#c;d'e\"f", + 'ffmpeg http://[::1]:8080/ch/1', + 'auto http://host/канал/1', + ]; + for (const cmd of corpus) { + const query = `cmd=${encodeStalkerCmdValue(cmd)}`; + expect(new URL(`http://portal/load.php?${query}`).search).toBe( + `?${query}` + ); + } + }); + + it('is value-preserving after one server-side decode for every escaped character', () => { + const original = `ffrt http://h/s?a=1&b=2#f;g "q" 'x' я`; + expect(portalVisibleValue(encodeStalkerCmdValue(original))).toBe( + original + ); + }); +}); diff --git a/libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.ts b/libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.ts new file mode 100644 index 000000000..9ea6dae0c --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.ts @@ -0,0 +1,55 @@ +/** + * Wire encoding for the Stalker `cmd` query parameter. + * + * A real MAG/STB sends `cmd` unencoded: the portal's own client JS + * concatenates raw `key=value` pairs, WebKit's URL layer escapes only the + * characters a URL cannot carry (space, quotes, non-ASCII), and PHP's `$_GET` + * then applies exactly one form-urldecode. The portal therefore sees the + * stored cmd decoded **once** — pre-encoded sequences such as `%3A` arrive as + * `:`, and a literal `+` arrives as a space. + * + * `encodeURIComponent` (the previous behavior) broke that contract for any + * cmd already containing `%`: `%3A` went out as `%253A` and reached the + * portal still encoded, so strict panels and the stock + * `preg_match`-based `create_link` handlers saw a different string than a + * real STB would send. + * + * This encoder reproduces the reference wire bytes instead: + * + * - `%` passes through untouched (never double-encoded); + * - characters the WHATWG URL serializer keeps raw in a query stay raw + * (`/ : ? = + , @ $ [ ] ! * ( ) ~ - _ .`), so the bytes we emit survive + * `new URL(...)` unchanged; + * - everything else is percent-encoded. That covers the characters that + * would restructure our request (`&`, `#`, and `;` for PHP setups with a + * `;` argument separator) — a malicious portal cannot smuggle extra query + * parameters through cmd — plus characters a URL cannot carry raw (space, + * quotes, control characters, non-ASCII). All of them decode back to the + * original byte on the server, so the portal-visible value is unaffected. + */ + +const SAFE_CMD_CHAR = /^[A-Za-z0-9\-_.~!*()/:?@$,+=[\]%]$/; + +const utf8Encoder = new TextEncoder(); + +/** + * Percent-encode every UTF-8 byte of a character. Deliberately not + * `encodeURIComponent`, whose unreserved set (e.g. `'`) overlaps characters + * this encoder must escape because `new URL(...)` would re-encode them and + * change the wire bytes behind our back. + */ +function percentEncodeChar(char: string): string { + let encoded = ''; + for (const byte of utf8Encoder.encode(char)) { + encoded += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`; + } + return encoded; +} + +export function encodeStalkerCmdValue(value: string): string { + let encoded = ''; + for (const char of value) { + encoded += SAFE_CMD_CHAR.test(char) ? char : percentEncodeChar(char); + } + return encoded; +} From e86e988e721b8f55e0a3a0f14294223ab29165b0 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:11:11 +0200 Subject: [PATCH 08/18] feat(ui): turn the phone context panel into an off-canvas drawer (#1332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): turn the phone context panel into an off-canvas drawer On ≤640px viewports the workspace context panel (categories, filters, settings sections, collection filters) no longer stacks above the route content capped at 30vh — it is a hidden-by-default drawer that slides in from the left over a backdrop, opened via a new header toggle (phone-only, CSS-gated) and closed by selection, backdrop tap, Escape, or any navigation. State lives in the new WorkspaceShellContextDrawerService provided by the shell component; panels close it explicitly after selections that do not navigate (Stalker ITV/radio categories, settings sections, sources filters, collection filters), since NavigationEnd alone cannot cover those. Desktop behavior is untouched, including the ResizableDirective inline width. Closes the drawer follow-up deferred from #1100 / PR #1326. Co-Authored-By: Claude Fable 5 * fix(ui): make the phone context drawer modal for keyboard users Addresses Greptile P1 and Codex P2 review feedback on #1332: - CdkTrapFocus on the sidebar captures focus into the drawer on open and contains it while the drawer is modal; the shell restores focus to the header toggle on close, since the closed drawer is visibility: hidden and focus left inside it would silently drop to . - The drawer service closes the drawer when the viewport leaves the phone breakpoint (matchMedia), so the trap can never hold the in-flow desktop sidebar after a resize. - The toggle's tooltip and aria-label are now variant-aware — categories on portal routes, filters on sources/collection routes, settings sections on the settings route — instead of a fixed 'Categories & filters' that misdescribed two of the three; the two generic i18n keys are replaced by six variant keys across all 19 locales. Co-Authored-By: Claude Fable 5 * fix(ui): remove background content from the a11y tree while the drawer is open Round-2 review feedback on #1332 (Greptile P1, Codex P2): - The rail, header, route content and playback footer are marked inert while the phone drawer is open — CdkTrapFocus constrains Tab focus, but a screen reader's virtual cursor could still reach and activate the visually obscured controls behind the backdrop. - The drawer panel itself is the trap's initial focus target (tabindex=-1 + cdkFocusInitial), so focus capture still works when a category list is loading, empty, or failed and renders no focusable rows. - Focus restore on close is deferred one tick: the toggle lives in the inert header, and focus() on a still-inert element is silently ignored. Co-Authored-By: Claude Fable 5 * fix(playback): gate global shortcuts and Escape behind the open phone drawer Round-3 review feedback on #1332 (Codex P2s): - The shell consumes Escape while the drawer is open: downstream Escape consumers (the portal detail shell's inline player close, the shared controls shortcuts) check defaultPrevented, so one keypress no longer closes both the drawer and the obscured playback surface. - inert does not silence document-level keydown listeners, so players opt out themselves while inside an inert region: ControlsShortcuts gains an optional hostElement handler and ignores every shortcut (including Escape) when that host has an inert ancestor, and the radio audio player applies the same check to its volume/mute keys. Co-Authored-By: Claude Fable 5 * fix(ui): close control, Cmd+F gate, and Embedded MPV inert guard for the drawer Round-4 review feedback on #1332 (Greptile P1, Codex P2s): - The drawer carries its own phone-only close button: touch screen-reader users have no hardware Escape and cannot reach the inert header toggle or the aria-hidden backdrop, so the trapped surface must offer dismissal itself — even when a category list is loading or empty and renders no actionable entries. - Ctrl/Cmd+F no longer opens global search while the drawer is modal; the shortcut would have navigated and focused an input inside the inert header. - EmbeddedMpvShortcuts (native-view legacy dock) gains the same hostElement/inert-ancestor guard as the shared controls shortcuts, so the obscured player cannot react to Space/arrows/M/Escape behind the drawer. Co-Authored-By: Claude Fable 5 * fix(ui): round-5 drawer feedback + update phone-layout e2e for the drawer Merges master (#1333 landed mobile-layout.e2e.ts pinning the #1326 stacked-panel behavior this PR replaces) and updates that spec to pin the drawer contract instead: panel hidden by default with full-width content, header toggle opens it over a backdrop, category selection and backdrop tap close it. Verified locally on Chromium, Firefox and WebKit (12/12). The spec's getByTestId calls needed plain [data-test-id=...] locators — the web-e2e Playwright config never mapped testIdAttribute. Also addresses Codex round-5 P2s: - Focus restore now reports whether the toggle received focus; when a drawer selection navigated to a route without a context panel (toggle gone), focus falls back to the route content instead of dropping to . - The Xtream and Stalker live layouts' Ctrl/Cmd+B sidebar shortcut opts out while their host sits inside an inert region, matching the other document-level listeners. Co-Authored-By: Claude Fable 5 * fix(ui): suppress command palette and shortcuts dialog behind the open drawer Greptile round-6 finding on #1332: the document-level Ctrl/Cmd+K handler in WorkspaceShellFacade and the '?' help-key handler in WorkspaceKeyboardShortcutsService still opened their dialogs while the phone context drawer was modal, stacking a second focus-trapped surface on top of it. Both now check the drawer service (injected optionally, same shell-component providers) and stay quiet while it is open, like the Ctrl/Cmd+F global-search gate. Co-Authored-By: Claude Fable 5 * fix(ui): round-7 drawer feedback — Cmd+R gate and native MPV surface hiding Addresses the two Codex round-7 P2s on #1332: - WorkspaceShellContextDrawerService moves to @iptvnator/workspace/shell/util and becomes root-provided, so AppComponent's document-level Ctrl/Cmd+R global-recent shortcut can observe the modal drawer without pulling the lazy shell chunk into the eager bundle. Cmd+R is now suppressed while the drawer is open, like Cmd+F/Cmd+K/'?'. - The shell registers the open drawer with a new EmbeddedMpvOverlayVisibilityService.acquireExternalModalSurface() API: the native-view video surface is composited outside DOM stacking and would paint straight over the drawer regardless of z-index. The service treats registered external modal surfaces exactly like open Material dialogs. - The service's recompute no longer reads overlayActive back before setting it: signals already skip notification on equal values, and that hidden read registered overlayActive as a dependency of any reactive context calling into the service — the shell's acquire/release effect looped forever on exactly that (caught by a live browser probe; the unit suite mocked the service). The effect also wraps the acquire in untracked() for caller-side hygiene. Co-Authored-By: Claude Fable 5 * fix(ui): expose the phone drawer as a named modal dialog Round-8 review feedback on #1332 (Codex P2s): - While open, the drawer carries role=dialog, aria-modal=true, and a variant-appropriate accessible name (categories / filters / settings sections) — assistive technology now hears that a named modal surface opened instead of an unnamed complementary landmark. Closed (and the always-visible desktop sidebar) stays a plain landmark. - The UI-guidelines drawer section no longer claims the drawer service is component-provided; it is root-provided from workspace/shell/util since the round-7 move, and the stale claim could have led a future change to re-scope it and silently break the AppComponent shortcut gate and the Embedded MPV overlay observer. Matching code comments updated everywhere. Co-Authored-By: Claude Fable 5 * fix(ui): round-9 — gate M3U player keys behind the drawer, raise drawer stacking Greptile round-9 P1 + Codex round-9 P2 on #1332: - The M3U video player's document-level digit-key channel switching and Ctrl/Cmd+B sidebar toggle now apply the same inert-ancestor guard as every other routed-content key listener. A codebase sweep confirms this closes the class: every document-level key listener on routed content is now either gated by the shell (Escape, Cmd+F/K/R, '?') or opts out via closest('[inert]'); the guidelines now require the guard for any new listener. - The drawer moves from z-index 99/98 to 951/950: above the settings action bar (100) and the root EPG/update panels (900/901), which inert removes from interaction but not from paint order — below the CDK overlay container (1000), since dialogs opened from inside the drawer (Manage categories) must stack on top of it. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .changes/ui-phone-context-drawer.md | 10 + apps/web-e2e/src/mobile-layout.e2e.ts | 72 +++-- apps/web/src/app/app.component.spec.ts | 11 +- apps/web/src/app/app.component.ts | 12 +- apps/web/src/assets/i18n/ar.json | 6 + apps/web/src/assets/i18n/ary.json | 6 + apps/web/src/assets/i18n/by.json | 6 + apps/web/src/assets/i18n/de.json | 6 + apps/web/src/assets/i18n/el.json | 6 + apps/web/src/assets/i18n/en.json | 6 + apps/web/src/assets/i18n/es.json | 6 + apps/web/src/assets/i18n/fr.json | 6 + apps/web/src/assets/i18n/hu.json | 6 + apps/web/src/assets/i18n/it.json | 6 + apps/web/src/assets/i18n/ja.json | 6 + apps/web/src/assets/i18n/ko.json | 6 + apps/web/src/assets/i18n/nl.json | 6 + apps/web/src/assets/i18n/pl.json | 6 + apps/web/src/assets/i18n/pt.json | 6 + apps/web/src/assets/i18n/ru.json | 6 + apps/web/src/assets/i18n/tr.json | 6 + apps/web/src/assets/i18n/zh.json | 6 + apps/web/src/assets/i18n/zhtw.json | 6 + docs/architecture/iptvnator-ui-guidelines.md | 54 +++- docs/architecture/player-controls-contract.md | 9 + .../video-player/video-player.component.ts | 9 + .../stalker-live-stream-layout.component.ts | 7 +- .../live-stream-layout.component.ts | 6 +- .../audio-player.component.spec.ts | 15 + .../audio-player/audio-player.component.ts | 6 + ...ded-mpv-overlay-visibility.service.spec.ts | 69 ++++ ...embedded-mpv-overlay-visibility.service.ts | 36 ++- .../embedded-mpv-player.component.ts | 1 + .../embedded-mpv-shortcuts.spec.ts | 25 ++ .../embedded-mpv-shortcuts.ts | 14 + .../controls-shortcuts.spec.ts | 40 +++ .../lib/player-controls/controls-shortcuts.ts | 15 + .../player-controls.component.ts | 1 + ...pace-collection-context-panel.component.ts | 7 + .../workspace-context-panel.component.spec.ts | 7 + .../workspace-context-panel.component.ts | 10 + ...kspace-settings-context-panel.component.ts | 18 +- ...rkspace-keyboard-shortcuts.service.spec.ts | 25 ++ .../workspace-keyboard-shortcuts.service.ts | 12 +- ...space-shell-context-sidebar.component.html | 24 ++ ...space-shell-context-sidebar.component.scss | 112 +++++-- ...ce-shell-context-sidebar.component.spec.ts | 43 +++ ...rkspace-shell-context-sidebar.component.ts | 15 + .../workspace-shell-header.component.html | 19 ++ .../workspace-shell-header.component.scss | 12 + .../workspace-shell-header.component.spec.ts | 64 ++++ .../workspace-shell-header.component.ts | 48 +++ .../workspace-shell-route-state.service.ts | 51 +++ .../services/workspace-shell.facade.spec.ts | 32 ++ .../services/workspace-shell.facade.ts | 12 + .../workspace-shell.component.html | 57 +++- .../workspace-shell.component.scss | 24 +- .../workspace-shell.component.spec.ts | 295 +++++++++++++++++- .../workspace-shell.component.ts | 93 +++++- ...rkspace-sources-filters-panel.component.ts | 8 + libs/workspace/shell/util/src/index.ts | 1 + ...space-shell-context-drawer.service.spec.ts | 116 +++++++ .../workspace-shell-context-drawer.service.ts | 73 +++++ 63 files changed, 1626 insertions(+), 78 deletions(-) create mode 100644 .changes/ui-phone-context-drawer.md create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-overlay-visibility.service.spec.ts create mode 100644 libs/workspace/shell/util/src/lib/workspace-shell-context-drawer.service.spec.ts create mode 100644 libs/workspace/shell/util/src/lib/workspace-shell-context-drawer.service.ts diff --git a/.changes/ui-phone-context-drawer.md b/.changes/ui-phone-context-drawer.md new file mode 100644 index 000000000..24fb14fac --- /dev/null +++ b/.changes/ui-phone-context-drawer.md @@ -0,0 +1,10 @@ +--- +type: feature +area: ui +issues: [1100] +--- + +On phone-sized screens the categories and filters panel no longer sits stacked +above the content — it is now a slide-in drawer, opened from a new button in +the header and closed by picking an entry, tapping outside it, or pressing +Escape. The content gets the whole screen while browsing. diff --git a/apps/web-e2e/src/mobile-layout.e2e.ts b/apps/web-e2e/src/mobile-layout.e2e.ts index ff27a965f..71c1be549 100644 --- a/apps/web-e2e/src/mobile-layout.e2e.ts +++ b/apps/web-e2e/src/mobile-layout.e2e.ts @@ -9,19 +9,20 @@ import { /** * Mobile Layout Smoke Tests * - * PR #1326 made the workspace usable on phone-sized screens (issue #1100) - * with SCSS-only changes and no automated coverage. These tests pin the - * invariants that regressed before: + * PR #1326 made the workspace usable on phone-sized screens (issue #1100); + * the follow-up drawer PR turned the phone context panel into an off-canvas + * drawer. These tests pin the invariants that regressed before: * * 1. No horizontal overflow — document.documentElement.scrollWidth stays * within the viewport on dashboard, Xtream VOD/live, and settings. * 2. The workspace rail links render inside the 52px top bar instead of * stacking downwards over the header. - * 3. On a portal route the context panel and the content stack: the - * content keeps roughly the full viewport width instead of the ~50px - * a persisted desktop panel width used to leave it. + * 3. On a portal route the context panel is an off-canvas drawer: hidden + * by default so the content keeps the full viewport width, opened from + * the header toggle (winning over the persisted desktop inline width), + * and closed again by picking a category or tapping the backdrop. * 4. The settings section list scrolls instead of painting over the - * Back footer. + * Back footer — now inside the open drawer. * 5. On a 640x360 landscape phone the live route keeps the channel * sidebar at least 72px tall and the player container inside the * viewport. @@ -106,18 +107,24 @@ test.describe('portrait phone 375x812', () => { await expectRailLinksInsideTopBar(page); }); - test('@mobile settings section list stays clear of the Back footer', async ({ + test('@mobile settings drawer opens from the header toggle and keeps the section list clear of the Back footer', async ({ page, }) => { await page.goto('/workspace/settings'); + // The phone context panel is an off-canvas drawer: hidden until the + // header toggle opens it, so the settings content owns the pane. const panel = page.locator('.context-panel--settings'); + await expect(panel).toBeHidden(); + + await page.locator('[data-test-id="context-drawer-toggle"]').click(); await expect(panel).toBeVisible(); - // The panel stacks above the content at full width even though - // ResizableDirective writes its persisted desktop width inline. + // Narrower than the viewport so the backdrop stays tappable, and + // wider than the persisted desktop inline width would leave it. const panelBox = await boxOf(panel); - expect(panelBox.width).toBeGreaterThanOrEqual(PHONE.width - 2); + expect(panelBox.width).toBeGreaterThanOrEqual(300); + expect(panelBox.width).toBeLessThanOrEqual(PHONE.width - 20); const footer = panel.locator('.settings-panel-footer'); await expect(footer.locator('.settings-back-button')).toBeVisible(); @@ -131,6 +138,12 @@ test.describe('portrait phone 375x812', () => { footerBox.y + 1 ); + // Tapping the backdrop (right of the drawer) closes it. + await page + .locator('[data-test-id="context-drawer-backdrop"]') + .click({ position: { x: PHONE.width - 10, y: 400 } }); + await expect(panel).toBeHidden(); + await expectNoHorizontalOverflow(page, PHONE.width); }); }); @@ -149,31 +162,38 @@ test.describe('xtream portal routes on a phone', () => { await addXtreamPortal(page, 'Mobile Layout Portal'); }); - test('@mobile @xtream vod route stacks the context panel above full-width content', async ({ + test('@mobile @xtream vod route keeps the context panel in a drawer behind the header toggle', async ({ page, }) => { await page.setViewportSize(PHONE); + // Hidden by default — the content owns the full pane. This is the + // successor to the #1326 stacked layout, which left the content + // only the leftover under a 30vh panel. const panel = page.locator('.context-panel'); - await expect(panel).toBeVisible(); - - // The persisted desktop width is written as an inline style; the - // phone rule must win with `width: 100% !important`, otherwise the - // panel keeps ~300px of a 375px screen. - await expect - .poll(async () => (await panel.boundingBox())?.width ?? 0) - .toBeGreaterThanOrEqual(PHONE.width - 2); + await expect(panel).toBeHidden(); const content = page.locator('main.workspace-content'); const contentWidth = await content.evaluate((el) => el.clientWidth); expect(contentWidth).toBeGreaterThanOrEqual(PHONE.width - 20); - // Stacked, not side by side: the content starts below the panel. - const panelBox = await boxOf(panel); - const contentBox = await boxOf(content); - expect(contentBox.y).toBeGreaterThanOrEqual( - panelBox.y + panelBox.height - 1 - ); + // The header toggle slides the drawer in. The persisted desktop + // width is written as an inline style; the drawer rule must win + // with `width: 100% !important` of its ~320px surface, otherwise + // the panel keeps its desktop width fraction. + await page.locator('[data-test-id="context-drawer-toggle"]').click(); + await expect(panel).toBeVisible(); + await expect + .poll(async () => (await panel.boundingBox())?.width ?? 0) + .toBeGreaterThanOrEqual(300); + + // Picking a category both filters the route and closes the drawer. + const firstCategory = page + .locator('.context-panel .category-item') + .first(); + await expect(firstCategory).toBeVisible(); + await firstCategory.click(); + await expect(panel).toBeHidden(); await expectNoHorizontalOverflow(page, PHONE.width); await expectRailLinksInsideTopBar(page); diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index 965e90dda..0aa298fe4 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -8,7 +8,10 @@ import { EpgRuntimeBridgeService, EpgService, } from '@iptvnator/epg/data-access'; -import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util'; +import { + WorkspaceShellContextDrawerService, + WORKSPACE_SHELL_ACTIONS, +} from '@iptvnator/workspace/shell/util'; import { MockProvider } from 'ng-mocks'; import { EMPTY, of } from 'rxjs'; import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; @@ -114,6 +117,12 @@ describe('AppComponent', () => { MockProvider(Router, { navigateByUrl: jest.fn(), }), + { + // Root-provided in production; stubbed because the spec's + // Router mock has no `events` stream for the real service. + provide: WorkspaceShellContextDrawerService, + useValue: { isOpen: () => false }, + }, MockProvider(MatSnackBar, { open: jest.fn(), }), diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index cea7f4f70..dd1f52828 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -8,7 +8,10 @@ import { EpgRuntimeBridgeService, EpgService, } from '@iptvnator/epg/data-access'; -import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util'; +import { + WorkspaceShellContextDrawerService, + WORKSPACE_SHELL_ACTIONS, +} from '@iptvnator/workspace/shell/util'; import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel'; import { WindowControlsComponent } from '@iptvnator/ui/components'; import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; @@ -62,6 +65,7 @@ export class AppComponent implements OnInit { private playlistOpenRequests = inject(PlaylistOpenRequestService); private runtime = inject(RuntimeCapabilitiesService); private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS); + private readonly contextDrawer = inject(WorkspaceShellContextDrawerService); /** Default language as fallback */ private readonly DEFAULT_LANG = Language.ENGLISH; @@ -87,6 +91,12 @@ export class AppComponent implements OnInit { if (this.runtime.isElectron) { document.addEventListener('keydown', (event) => { if (event.ctrlKey || event.metaKey) { + // While the phone context drawer is modal, workspace + // shortcuts must not navigate away behind it — same gate + // as Ctrl/Cmd+F, Ctrl/Cmd+K, and the shortcuts dialog. + if (this.contextDrawer.isOpen()) { + return; + } if (event.key === 'r') { event.preventDefault(); this.workspaceShellActions.openGlobalRecent(); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 985a404b2..b14d45fa3 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "القنوات المحمّلة فقط", "COMMAND_PALETTE_SHORTCUT": "لوحة الأوامر (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "فتح لوحة الأوامر", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "تبديل لوحة الفئات", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "الفئات", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "تبديل لوحة التصفية", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "التصفية", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "تبديل لوحة أقسام الإعدادات", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "أقسام الإعدادات", "ADD_PLAYLIST_ALL": "إضافة قائمة تشغيل", "ADD_PLAYLIST": "إضافة قائمة تشغيل", "OPEN_DOWNLOADS": "فتح التنزيلات", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 063ce9b93..d7845a1b8 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "غير القنوات المشرّجة", "COMMAND_PALETTE_SHORTCUT": "لوحة الأوامر (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "حل لوحة الأوامر", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "بدل لوحة الفئات", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "الفئات", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "بدل لوحة الفلاتر", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "الفلاتر", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "بدل لوحة أقسام الإعدادات", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "أقسام الإعدادات", "ADD_PLAYLIST_ALL": "زيد قائمة تشغيل", "ADD_PLAYLIST": "زيد قائمة تشغيل", "OPEN_DOWNLOADS": "حل التحميلات", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index de8a54ec2..d6636bc64 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "Толькі загружаныя каналы", "COMMAND_PALETTE_SHORTCUT": "Палітра каманд (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "Адкрыць палітру каманд", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Паказаць або схаваць панэль катэгорый", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Катэгорыі", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "Паказаць або схаваць панэль фільтраў", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "Фільтры", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "Паказаць або схаваць панэль раздзелаў налад", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Раздзелы налад", "ADD_PLAYLIST_ALL": "Дадаць плэйліст", "ADD_PLAYLIST": "Дадаць плэйліст", "OPEN_DOWNLOADS": "Адкрыць спампаванні", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 79ba1139e..277f8fda0 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "Nur geladene Sender", "COMMAND_PALETTE_SHORTCUT": "Befehlspalette (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "Befehlspalette öffnen", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Kategorienbereich ein- oder ausblenden", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Kategorien", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "Filterbereich ein- oder ausblenden", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filter", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "Einstellungsbereiche ein- oder ausblenden", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Einstellungsbereiche", "ADD_PLAYLIST_ALL": "Playlist hinzufügen (alle Playlists)", "ADD_PLAYLIST": "Playlist hinzufügen", "OPEN_DOWNLOADS": "Downloads öffnen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 60460a449..dfa78a790 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "Μόνο φορτωμένα κανάλια", "COMMAND_PALETTE_SHORTCUT": "Παλέτα εντολών (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "Άνοιγμα παλέτας εντολών", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Εναλλαγή του πίνακα κατηγοριών", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Κατηγορίες", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "Εναλλαγή του πίνακα φίλτρων", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "Φίλτρα", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "Εναλλαγή του πίνακα ενοτήτων ρυθμίσεων", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Ενότητες ρυθμίσεων", "ADD_PLAYLIST_ALL": "Προσθήκη λίστας αναπαραγωγής", "ADD_PLAYLIST": "Προσθήκη λίστας αναπαραγωγής", "OPEN_DOWNLOADS": "Άνοιγμα λήψεων", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 7821ca2f8..7e3523182 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "Loaded channels only", "COMMAND_PALETTE_SHORTCUT": "Command palette (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "Open command palette", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Toggle the categories panel", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Categories", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "Toggle the filters panel", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filters", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "Toggle the settings sections panel", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Settings sections", "ADD_PLAYLIST_ALL": "Add playlist", "ADD_PLAYLIST": "Add playlist", "OPEN_DOWNLOADS": "Open downloads", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 1c678c123..e446f7958 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "Solo canales cargados", "COMMAND_PALETTE_SHORTCUT": "Paleta de comandos (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "Abrir paleta de comandos", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Mostrar u ocultar el panel de categorías", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Categorías", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "Mostrar u ocultar el panel de filtros", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filtros", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "Mostrar u ocultar el panel de secciones de ajustes", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Secciones de ajustes", "ADD_PLAYLIST_ALL": "Agregar lista de reproducción", "ADD_PLAYLIST": "Agregar lista de reproducción", "OPEN_DOWNLOADS": "Abrir descargas", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 2806f61cd..486dab1f7 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "Chaînes chargées uniquement", "COMMAND_PALETTE_SHORTCUT": "Palette de commandes (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "Ouvrir la palette de commandes", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Afficher ou masquer le panneau des catégories", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Catégories", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "Afficher ou masquer le panneau des filtres", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filtres", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "Afficher ou masquer le panneau des sections des paramètres", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Sections des paramètres", "ADD_PLAYLIST_ALL": "Ajouter une liste de lecture", "ADD_PLAYLIST": "Ajouter une liste de lecture", "OPEN_DOWNLOADS": "Ouvrir les téléchargements", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 8800b87a5..0f26026a3 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "Csak betöltött csatornák", "COMMAND_PALETTE_SHORTCUT": "Parancspaletta (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "Parancspaletta megnyitása", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Kategóriapanel megjelenítése vagy elrejtése", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Kategóriák", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "Szűrőpanel megjelenítése vagy elrejtése", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "Szűrők", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "Beállítási szakaszok paneljének megjelenítése vagy elrejtése", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Beállítási szakaszok", "ADD_PLAYLIST_ALL": "Lejátszási lista hozzáadása", "ADD_PLAYLIST": "Lejátszási lista hozzáadása", "OPEN_DOWNLOADS": "Letöltések megnyitása", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 37e6aad3e..585e5706f 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "Solo canali caricati", "COMMAND_PALETTE_SHORTCUT": "Tavolozza comandi (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "Apri tavolozza comandi", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Mostra o nascondi il pannello delle categorie", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Categorie", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "Mostra o nascondi il pannello dei filtri", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filtri", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "Mostra o nascondi il pannello delle sezioni delle impostazioni", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Sezioni delle impostazioni", "ADD_PLAYLIST_ALL": "Aggiungi playlist", "ADD_PLAYLIST": "Aggiungi playlist", "OPEN_DOWNLOADS": "Apri download", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index ac99cd73b..9b557ba75 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "読み込み済みチャンネルのみ", "COMMAND_PALETTE_SHORTCUT": "コマンドパレット(Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "コマンドパレットを開く", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "カテゴリーパネルを切り替え", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "カテゴリー", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "フィルターパネルを切り替え", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "フィルター", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "設定セクションパネルを切り替え", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "設定セクション", "ADD_PLAYLIST_ALL": "プレイリストを追加", "ADD_PLAYLIST": "プレイリストを追加", "OPEN_DOWNLOADS": "ダウンロードを開く", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index eb96e5988..9e4c7f322 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "불러온 채널만", "COMMAND_PALETTE_SHORTCUT": "명령어 팔레트 (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "명령어 팔레트 열기", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "카테고리 패널 전환", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "카테고리", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "필터 패널 전환", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "필터", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "설정 섹션 패널 전환", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "설정 섹션", "ADD_PLAYLIST_ALL": "재생목록 추가", "ADD_PLAYLIST": "재생목록 추가", "OPEN_DOWNLOADS": "다운로드 열기", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 304037f57..58b3d7044 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "Alleen geladen kanalen", "COMMAND_PALETTE_SHORTCUT": "Opdrachtenpalet (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "Open opdrachtenpalet", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Categorieënpaneel tonen of verbergen", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Categorieën", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "Filterpaneel tonen of verbergen", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filters", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "Paneel met instellingensecties tonen of verbergen", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Instellingensecties", "ADD_PLAYLIST_ALL": "Afspeellijst toevoegen", "ADD_PLAYLIST": "Afspeellijst toevoegen", "OPEN_DOWNLOADS": "Open downloads", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index b12587b2d..47aa42d8d 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "Tylko załadowane kanały", "COMMAND_PALETTE_SHORTCUT": "Paleta poleceń (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "Otwórz paletę poleceń", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Pokaż lub ukryj panel kategorii", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Kategorie", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "Pokaż lub ukryj panel filtrów", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filtry", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "Pokaż lub ukryj panel sekcji ustawień", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Sekcje ustawień", "ADD_PLAYLIST_ALL": "Dodaj listę odtwarzania", "ADD_PLAYLIST": "Dodaj listę odtwarzania", "OPEN_DOWNLOADS": "Otwórz pobrane", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 568d7c8d1..88f056e3f 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "Apenas canais carregados", "COMMAND_PALETTE_SHORTCUT": "Paleta de comandos (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "Abrir paleta de comandos", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Mostrar ou ocultar o painel de categorias", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Categorias", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "Mostrar ou ocultar o painel de filtros", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filtros", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "Mostrar ou ocultar o painel de seções das configurações", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Seções das configurações", "ADD_PLAYLIST_ALL": "Adicionar playlist", "ADD_PLAYLIST": "Adicionar playlist", "OPEN_DOWNLOADS": "Abrir downloads", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 7d7d470a5..14492bf55 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "Только загруженные каналы", "COMMAND_PALETTE_SHORTCUT": "Палитра команд (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "Открыть палитру команд", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Показать или скрыть панель категорий", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Категории", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "Показать или скрыть панель фильтров", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "Фильтры", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "Показать или скрыть панель разделов настроек", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Разделы настроек", "ADD_PLAYLIST_ALL": "Добавить плейлист (все плейлисты)", "ADD_PLAYLIST": "Добавить плейлист", "OPEN_DOWNLOADS": "Открыть загрузки", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 41defae57..f1fcb6d6d 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "Yalnızca yüklenmiş kanallar", "COMMAND_PALETTE_SHORTCUT": "Komut paleti (Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "Komut paletini aç", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Kategori panelini aç/kapat", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Kategoriler", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "Filtre panelini aç/kapat", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filtreler", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "Ayar bölümleri panelini aç/kapat", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Ayar bölümleri", "ADD_PLAYLIST_ALL": "Oynatma listesi ekle", "ADD_PLAYLIST": "Oynatma listesi ekle", "OPEN_DOWNLOADS": "İndirilenleri aç", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 1734eb2ec..121218bd1 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "仅已加载的频道", "COMMAND_PALETTE_SHORTCUT": "命令面板(Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "打开命令面板", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "切换分类面板", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "分类", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "切换筛选面板", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "筛选", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "切换设置栏目面板", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "设置栏目", "ADD_PLAYLIST_ALL": "添加播放列表", "ADD_PLAYLIST": "添加播放列表", "OPEN_DOWNLOADS": "打开下载", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index ac83eed93..00bde5baa 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1248,6 +1248,12 @@ "SEARCH_STATUS_LOADED_ONLY": "僅限已載入的頻道", "COMMAND_PALETTE_SHORTCUT": "命令選擇區(Ctrl/Cmd+K)", "OPEN_COMMAND_PALETTE": "開啟命令選擇區", + "CONTEXT_DRAWER_CATEGORIES_TOGGLE": "切換類別面板", + "CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "類別", + "CONTEXT_DRAWER_FILTERS_TOGGLE": "切換篩選面板", + "CONTEXT_DRAWER_FILTERS_TOOLTIP": "篩選", + "CONTEXT_DRAWER_SETTINGS_TOGGLE": "切換設定區段面板", + "CONTEXT_DRAWER_SETTINGS_TOOLTIP": "設定區段", "ADD_PLAYLIST_ALL": "新增播放清單", "ADD_PLAYLIST": "新增播放清單", "OPEN_DOWNLOADS": "開啟下載", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 7e0e4b39d..4ff2b5b60 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -340,15 +340,63 @@ inventing a nearby value: several surfaces cooperate at this width, and a component that picks `599px` leaves a band where the shell has already stacked but the component has not. -### Rails become rows or stacks +### Rails become rows, stacks, or drawers - The workspace shell rail turns into a horizontal top bar. Everything inside it has to opt into the row direction — a nested list that keeps `flex-direction: column` stacks its links out of the bar and over the header. The bar scrolls sideways once a portal contributes its sections, and the settings link is `position: sticky` so it never scrolls out of reach. -- Side rails stack above the content instead of beside it: the shell context - panel, the live-layout channel sidebar, and the M3U channel drawer. +- The shell context panel (categories, filters, settings sections) is an + off-canvas drawer: hidden by default so the route content owns the full + pane, opened from a toggle in the workspace header, closed by selection, + backdrop tap, Escape, or any navigation. State lives in + `WorkspaceShellContextDrawerService` (root-provided from + `@iptvnator/workspace/shell/util` — see below for why); the + panels call `close()` after selections that do not navigate — a + NavigationEnd listener alone misses Stalker ITV/radio categories, settings + sections, sources filters, and collection filters. The drawer positioning + is `position: fixed` on the sidebar host, which also removes it from the + shell grid, so the phone `workspace-body` stays single-pane. The drawer is + modal for keyboard and screen-reader users: `CdkTrapFocus` captures and + contains Tab focus while open, the shell marks the rail, header, content, + and playback footer `inert` (a focus trap alone does not stop a screen + reader's virtual cursor from activating obscured controls), the panel + itself is the initial focus target (`tabindex="-1"` + `cdkFocusInitial`, + so capture still works when a category list is loading or empty and + renders no focusable rows), and the shell restores focus to the header + toggle on close — deferred one tick, because the toggle is inside the + inert header and `focus()` on a still-inert element is silently ignored. + The service closes the drawer when the viewport leaves the phone + breakpoint so the trap and inert state can never hold the in-flow desktop + layout. While open, the shell consumes Escape (downstream consumers — + the inline player's close handler, the shared controls shortcuts — check + `defaultPrevented`, so one keypress cannot close both the drawer and the + obscured player) and suppresses workspace-level shortcuts (Ctrl/Cmd+F + global search, Ctrl/Cmd+K command palette, Ctrl/Cmd+R global recent, the + `?` shortcuts dialog — dialogs must not stack a second focus trap on the + modal drawer, and navigation must not act behind it), and document-level + shortcuts owned by routed content (shared controls, Embedded MPV legacy + dock, radio audio player, the live layouts' Ctrl/Cmd+B sidebar toggle, + the M3U player's digit-key channel switching and sidebar toggle) opt out + on their own by checking for an `inert` ancestor, since `inert` does not + silence document-level listeners. Any NEW document-level key listener on + routed content must apply the same `closest('[inert]')` guard. The service is root-provided + from `@iptvnator/workspace/shell/util` so consumers outside the shell's + element injector (AppComponent's Ctrl/Cmd+R handler) can observe it + without pulling the lazy shell chunk into the eager bundle. The shell + also registers the open drawer with + `EmbeddedMpvOverlayVisibilityService.acquireExternalModalSurface()`: + the native-view video surface is composited outside DOM stacking and + would paint straight over the drawer regardless of z-index. The drawer carries its own phone-only close + button: touch screen-reader users have no hardware Escape and cannot + reach the inert header toggle or the aria-hidden backdrop, so the + trapped surface itself must offer dismissal even when its list is + loading or empty. + The toggle's label is variant-aware — categories, filters, or settings + sections — because a fixed label would misdescribe two of the three. +- Other side rails stack above the content instead of beside it: the + live-layout channel sidebar and the M3U channel drawer. ### Resizable rails need `!important` diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 9a8778b07..8fc5d161f 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -257,6 +257,15 @@ popovers even when a modifier is held or playback shortcuts are unavailable. Buttons, form controls, links, ARIA menu controls, and content-editable targets are also ignored anywhere in the event's composed path. +A player whose host sits inside an `inert` region ignores every shortcut, +including Escape: `inert` strips pointer and Tab access but document-level +listeners still fire, so the optional `hostElement` handler on +`ControlsShortcutHandlers` lets the shortcuts opt out while a modal surface +above the player (e.g. the workspace's phone context drawer) owns the +keyboard. `EmbeddedMpvShortcutHandlers` (the native-view legacy dock) and +the radio audio player's document-level volume/mute keys apply the same +rule. + Action-specific keys are prevented only when the active controller can handle them: seek requires both capability and current seekability, volume/mute requires volume capability, and fullscreen requires an available DOM diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index dfeb98601..1a39c26fc 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -3,6 +3,7 @@ import { ComponentPortal } from '@angular/cdk/portal'; import { AsyncPipe, CommonModule } from '@angular/common'; import { Component, + ElementRef, HostListener, Injector, OnDestroy, @@ -143,6 +144,7 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460; }) export class VideoPlayerComponent implements OnInit, OnDestroy { private readonly activatedRoute = inject(ActivatedRoute); + private readonly hostElement = inject(ElementRef); private readonly dataService = inject(DataService); private readonly overlay = inject(Overlay); private readonly playlistsService = inject(PlaylistsService); @@ -887,6 +889,13 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { if (isTypingInInput(event)) { return; } + // Behind the workspace's phone context drawer the route content is + // inert; this document-level listener still fires, so it opts out + // itself instead of switching channels or toggling the sidebar + // behind the modal surface. + if (this.hostElement.nativeElement.closest('[inert]')) { + return; + } if ( (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b' diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 3ccbe97a2..a8c013a5f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -111,6 +111,7 @@ const FULL_LIST_RENDER_CHUNK = 100; export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly stalkerStore = inject(StalkerStore); private readonly playlistService = inject(PlaylistsService); + private readonly hostElement = inject(ElementRef); private readonly dialog = inject(MatDialog); private readonly epgBridge = inject(EpgRuntimeBridgeService); private readonly runtime = inject(RuntimeCapabilitiesService); @@ -727,7 +728,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { if ( (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b' && - !isTypingInInput(event) + !isTypingInInput(event) && + // Behind the workspace's phone context drawer the route content + // is inert; this document-level listener still fires, so it + // opts out itself instead of toggling an obscured sidebar. + !this.hostElement.nativeElement.closest('[inert]') ) { event.preventDefault(); this.toggleSidebar(); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 51986f145..cec491234 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -510,7 +510,11 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { if ( (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b' && - !isTypingInInput(event) + !isTypingInInput(event) && + // Behind the workspace's phone context drawer the route content + // is inert; this document-level listener still fires, so it + // opts out itself instead of toggling an obscured sidebar. + !this.hostElement.nativeElement.closest('[inert]') ) { event.preventDefault(); this.toggleSidebar(); diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts index b82104e09..6ff831007 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts @@ -122,6 +122,21 @@ describe('AudioPlayerComponent', () => { expect(component.volume()).toBe(0); }); + it('ignores keyboard shortcuts while inside an inert region', () => { + createComponent(); + component.setVolume(0.5); + // e.g. the workspace's phone context drawer marks the route content + // inert while open; document-level key listeners still fire, so the + // player must opt out itself. + fixture.nativeElement.setAttribute('inert', ''); + + const upEvent = createKeyboardEvent('ArrowUp'); + component.handleKeyboard(upEvent); + + expect(upEvent.preventDefault).not.toHaveBeenCalled(); + expect(component.volume()).toBe(0.5); + }); + it('restores the previous volume when unmuting', () => { const audio = createComponent(); component.setVolume(0.65); diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index c6e350106..45a1bdfa4 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -248,6 +248,12 @@ export class AudioPlayerComponent { ) return; + // Inside an inert region (e.g. behind the workspace's phone context + // drawer) the player is out of the interaction model: inert strips + // pointer and Tab access, but this document-level listener still + // fires, so it must opt out itself. + if (this.hostEl.nativeElement.closest('[inert]')) return; + if (event.key === 'ArrowUp') { event.preventDefault(); this.setVolume(this.volume() + 0.05); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-overlay-visibility.service.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-overlay-visibility.service.spec.ts new file mode 100644 index 000000000..17f5da4d9 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-overlay-visibility.service.spec.ts @@ -0,0 +1,69 @@ +import { TestBed } from '@angular/core/testing'; +import { OverlayContainer } from '@angular/cdk/overlay'; +import { MatDialog } from '@angular/material/dialog'; +import { Subject } from 'rxjs'; +import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service'; + +describe('EmbeddedMpvOverlayVisibilityService', () => { + let service: EmbeddedMpvOverlayVisibilityService; + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + TestBed.configureTestingModule({ + providers: [ + { + provide: OverlayContainer, + useValue: { getContainerElement: () => container }, + }, + { + provide: MatDialog, + useValue: { + openDialogs: [], + afterOpened: new Subject(), + afterAllClosed: new Subject(), + }, + }, + ], + }); + service = TestBed.inject(EmbeddedMpvOverlayVisibilityService); + }); + + it('starts with no active overlay', () => { + expect(service.overlayActive()).toBe(false); + }); + + it('treats a registered external modal surface like an open dialog', () => { + // e.g. the workspace's phone context drawer — a plain DOM panel the + // CDK-container observer can never see, but which must hide the + // native-view video that paints above DOM stacking. + const release = service.acquireExternalModalSurface(); + expect(service.overlayActive()).toBe(true); + + release(); + expect(service.overlayActive()).toBe(false); + }); + + it('stays active until every external surface is released', () => { + const releaseFirst = service.acquireExternalModalSurface(); + const releaseSecond = service.acquireExternalModalSurface(); + + releaseFirst(); + expect(service.overlayActive()).toBe(true); + + releaseSecond(); + expect(service.overlayActive()).toBe(false); + }); + + it('ignores a double release', () => { + const releaseFirst = service.acquireExternalModalSurface(); + const releaseSecond = service.acquireExternalModalSurface(); + + releaseFirst(); + releaseFirst(); + + expect(service.overlayActive()).toBe(true); + releaseSecond(); + expect(service.overlayActive()).toBe(false); + }); +}); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-overlay-visibility.service.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-overlay-visibility.service.ts index e5d5b3e40..d415d7e1a 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-overlay-visibility.service.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-overlay-visibility.service.ts @@ -9,6 +9,7 @@ export class EmbeddedMpvOverlayVisibilityService { private readonly overlayContainer = inject(OverlayContainer); private readonly dialog = inject(MatDialog); private observer: MutationObserver | null = null; + private externalModalSurfaces = 0; constructor() { this.dialog.afterOpened.subscribe(() => this.recompute()); @@ -26,15 +27,42 @@ export class EmbeddedMpvOverlayVisibilityService { this.recompute(); } + /** + * Registers a modal surface that does not live in the CDK overlay + * container — e.g. the workspace's phone context drawer, a plain + * translate-in DOM panel. The native-view video surface paints above + * DOM stacking, so any such surface must hide it exactly like a + * Material dialog does. Returns a release function; releasing twice is + * a no-op so effect cleanups can call it defensively. + */ + acquireExternalModalSurface(): () => void { + this.externalModalSurfaces += 1; + this.recompute(); + + let released = false; + return () => { + if (released) { + return; + } + released = true; + this.externalModalSurfaces -= 1; + this.recompute(); + }; + } + private recompute(): void { const dialogOpen = this.dialog.openDialogs.length > 0; const backdropPresent = this.overlayContainer .getContainerElement() .querySelector('.cdk-overlay-backdrop') !== null; - const next = dialogOpen || backdropPresent; - if (this.overlayActive() !== next) { - this.overlayActive.set(next); - } + const next = + dialogOpen || backdropPresent || this.externalModalSurfaces > 0; + // Plain set, no read-back guard: signals already skip notification + // for equal values, and reading overlayActive here would silently + // register it as a dependency of any reactive context that calls + // into this service (acquireExternalModalSurface from an effect + // looped forever on exactly that). + this.overlayActive.set(next); } } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index 7e4cc7806..d22b261cc 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -402,6 +402,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { isAvailable: () => this.legacyInteractions.isAvailable() && !this.overlayVisibility.overlayActive(), + hostElement: () => this.playerRoot()?.nativeElement ?? null, arrowKeysBlocked: () => this.menus.dockPanelOpen(), onEscape: () => this.legacyInteractions.closePopovers(), togglePaused: () => void this.togglePaused(), diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.spec.ts index 5c39d60d7..b1737051b 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.spec.ts @@ -30,6 +30,31 @@ describe('EmbeddedMpvShortcuts', () => { shortcuts.detach(); }); + it('ignores every shortcut while the host sits inside an inert region', () => { + const inertRegion = document.createElement('div'); + inertRegion.setAttribute('inert', ''); + const host = document.createElement('div'); + inertRegion.appendChild(host); + document.body.appendChild(inertRegion); + ( + handlers as unknown as { hostElement: () => HTMLElement } + ).hostElement = () => host; + + try { + expect(dispatchKey(' ')).toBe(false); + expect(dispatchKey('ArrowUp')).toBe(false); + expect(dispatchKey('m')).toBe(false); + dispatchKey('Escape'); + + expect(handlers.togglePaused).not.toHaveBeenCalled(); + expect(handlers.adjustVolume).not.toHaveBeenCalled(); + expect(handlers.toggleMute).not.toHaveBeenCalled(); + expect(handlers.onEscape).not.toHaveBeenCalled(); + } finally { + inertRegion.remove(); + } + }); + it('forwards playback, seek, volume, and fullscreen shortcuts', () => { expect(dispatchKey(' ')).toBe(true); expect(dispatchKey('ArrowLeft')).toBe(true); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.ts index 80f01e253..239024b54 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.ts @@ -1,5 +1,12 @@ export interface EmbeddedMpvShortcutHandlers { isAvailable: () => boolean; + /** + * The player's host element, used to opt out of every shortcut while an + * ancestor is `inert` (e.g. behind the workspace's phone context + * drawer): inert strips pointer and Tab access, but this document-level + * listener still fires, so shortcuts must check it themselves. + */ + hostElement?: () => HTMLElement | null; /** * While true, arrow keys stop seeking/adjusting volume — an open dock * chip panel owns them for chip navigation instead. @@ -37,6 +44,13 @@ export class EmbeddedMpvShortcuts { return; } + // Inside an inert region the player is outside the interaction + // model entirely — the modal surface above it owns the keyboard, + // including Escape. + if (handlers.hostElement?.()?.closest('[inert]')) { + return; + } + if (event.key === 'Escape') { handlers.onEscape(); return; diff --git a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts index 64135ad46..74f4b6f2b 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts @@ -56,6 +56,46 @@ describe('ControlsShortcuts', () => { expect(handlers.toggleMute).toHaveBeenCalledTimes(1); }); + it('ignores every shortcut while the host sits inside an inert region', () => { + const inertRegion = document.createElement('div'); + inertRegion.setAttribute('inert', ''); + const host = document.createElement('div'); + inertRegion.appendChild(host); + document.body.appendChild(inertRegion); + ( + handlers as unknown as { hostElement: () => HTMLElement } + ).hostElement = () => host; + + try { + expect(dispatchKey(' ')).toBe(false); + expect(dispatchKey('ArrowUp')).toBe(false); + expect(dispatchKey('m')).toBe(false); + dispatchKey('Escape'); + + expect(handlers.togglePaused).not.toHaveBeenCalled(); + expect(handlers.adjustVolume).not.toHaveBeenCalled(); + expect(handlers.toggleMute).not.toHaveBeenCalled(); + expect(handlers.onEscape).not.toHaveBeenCalled(); + } finally { + inertRegion.remove(); + } + }); + + it('handles shortcuts when the host is not inside an inert region', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + ( + handlers as unknown as { hostElement: () => HTMLElement } + ).hostElement = () => host; + + try { + expect(dispatchKey(' ')).toBe(true); + expect(handlers.togglePaused).toHaveBeenCalledTimes(1); + } finally { + host.remove(); + } + }); + it('does not consume keys for unsupported actions', () => { handlers.canTogglePaused.mockReturnValue(false); handlers.canSeek.mockReturnValue(false); diff --git a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts index 696925149..058745b1e 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts @@ -1,5 +1,12 @@ export interface ControlsShortcutHandlers { isAvailable: () => boolean; + /** + * The player's host element, used to opt out of global shortcuts while + * an ancestor is `inert` (e.g. behind the workspace's phone context + * drawer): inert strips pointer and Tab access, but document-level + * keydown listeners still fire, so shortcuts must check it themselves. + */ + hostElement?: () => HTMLElement | null; canTogglePaused: () => boolean; canSeek: () => boolean; canAdjustVolume: () => boolean; @@ -78,6 +85,14 @@ export class ControlsShortcuts { return; } + // A player inside an inert region is outside the interaction model + // entirely — a modal surface above it owns the keyboard. This also + // covers Escape: the modal decides what Escape means, not the + // obscured player. + if (handlers.hostElement?.()?.closest('[inert]')) { + return; + } + if (event.key === 'Escape') { handlers.onEscape(); return; diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts index 08fb2e1c9..32bb2e2c7 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts @@ -140,6 +140,7 @@ export class PlayerControlsComponent implements OnDestroy { constructor() { this.shortcuts.attach({ isAvailable: () => this.shortcutsEnabled() && this.showControls(), + hostElement: () => this.host, canTogglePaused: () => this.canTogglePlay(), canSeek: () => this.capabilities().seek && this.state().canSeek, canAdjustVolume: () => this.capabilities().volume, diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts index 70e0bab45..7a012bcbf 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts @@ -2,6 +2,7 @@ import { Component, computed, inject } from '@angular/core'; import { TranslatePipe } from '@ngx-translate/core'; import { PortalCollectionContextService } from '@iptvnator/portal/shared/util'; import { WorkspaceContextCategoryViewComponent } from './components/workspace-context-category-view.component'; +import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util'; /** * Context panel rendered in the workspace-shell aside for @@ -52,6 +53,11 @@ import { WorkspaceContextCategoryViewComponent } from './components/workspace-co }) export class WorkspaceCollectionContextPanelComponent { readonly ctx = inject(PortalCollectionContextService); + // Root-provided; optional keeps standalone unit tests light. Closes the phone + // drawer after a selection, which never navigates on this panel. + private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, { + optional: true, + }); readonly selectedCategory = computed(() => { const categories = this.ctx.categories(); @@ -70,5 +76,6 @@ export class WorkspaceCollectionContextPanelComponent { }): void { const id = String(item.category_id ?? item.id ?? 'all'); this.ctx.setCategoryId(id); + this.contextDrawer?.close(); } } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts index 0aa9e3457..2f20fe2e8 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts @@ -7,6 +7,7 @@ import { of } from 'rxjs'; import { MatDialog } from '@angular/material/dialog'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { WORKSPACE_CATEGORY_SORT_STORAGE_KEY } from '@iptvnator/portal/shared/util'; +import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util'; import { XtreamContentLoadState, XtreamStore, @@ -171,6 +172,12 @@ describe('WorkspaceContextPanelComponent', () => { provide: MatDialog, useValue: dialog, }, + { + // Root-provided in production; stubbed because the spec's + // Router mock has no `events` stream for the real service. + provide: WorkspaceShellContextDrawerService, + useValue: { close: jest.fn(), isOpen: () => false }, + }, ], }).compileComponents(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index 5ac3d3035..3843df70b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -28,6 +28,7 @@ import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { WorkspaceContextCategoryViewComponent } from './components/workspace-context-category-view.component'; import { WorkspaceContextErrorViewComponent } from './components/workspace-context-error-view.component'; import { hasActiveLiveCategoryRoute } from './workspace-context-panel-route.utils'; +import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util'; type WorkspaceProvider = 'xtreams' | 'stalker' | 'playlists'; @@ -69,6 +70,13 @@ export class WorkspaceContextPanelComponent { private readonly dialog = inject(MatDialog); private readonly destroyRef = inject(DestroyRef); private readonly translate = inject(TranslateService); + // Root-provided; optional keeps standalone unit tests light. Only relevant + // when the panel renders as the phone drawer. Some selections here (e.g. + // Stalker ITV/radio) update the store without navigating, so the drawer's + // NavigationEnd auto-close never fires for them. + private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, { + optional: true, + }); readonly context = input.required(); readonly section = input.required(); @@ -370,6 +378,7 @@ export class WorkspaceContextPanelComponent { return; } const categoryId = numericCategoryId; + this.contextDrawer?.close(); if (section === 'live') { this.xtreamStore.setSelectedCategory(categoryId); @@ -410,6 +419,7 @@ export class WorkspaceContextPanelComponent { const section = this.section(); const categoryId = String(item.category_id ?? '*'); + this.contextDrawer?.close(); this.stalkerStore.setSelectedCategory(categoryId); this.stalkerStore.setPage(0); this.stalkerStore.clearSelectedItem(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts index 50e1e1e23..7e13edd56 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts @@ -2,7 +2,10 @@ import { Location } from '@angular/common'; import { Component, inject } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { TranslateModule } from '@ngx-translate/core'; -import { SettingsContextService } from '@iptvnator/workspace/shell/util'; +import { + WorkspaceShellContextDrawerService, + SettingsContextService +} from '@iptvnator/workspace/shell/util'; @Component({ selector: 'app-workspace-settings-context-panel', @@ -18,7 +21,7 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util'; class="nav-item settings-section-item" [class.active]="ctx.activeSection() === section.id" [attr.data-test-id]="'settings-section-' + section.id" - (click)="ctx.navigateToSection(section.id)" + (click)="onSectionClicked(section.id)" > {{ section.icon }} {{ section.label | translate }} @@ -41,6 +44,17 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util'; export class WorkspaceSettingsContextPanelComponent { readonly ctx = inject(SettingsContextService); private readonly location = inject(Location); + // Root-provided; optional keeps standalone unit tests light. Section clicks + // scroll the settings page without navigating, so the phone drawer's + // NavigationEnd auto-close never fires for them. + private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, { + optional: true, + }); + + onSectionClicked(sectionId: string) { + this.ctx.navigateToSection(sectionId); + this.contextDrawer?.close(); + } onBack() { this.location.back(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts index 9dbaa0d37..90792c34f 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts @@ -3,6 +3,7 @@ import { MatDialog } from '@angular/material/dialog'; import { Subject } from 'rxjs'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { WorkspaceKeyboardShortcutsService } from './workspace-keyboard-shortcuts.service'; +import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util'; describe('WorkspaceKeyboardShortcutsService', () => { let afterClosed$: Subject; @@ -52,6 +53,30 @@ describe('WorkspaceKeyboardShortcutsService', () => { expect(dialog.open).toHaveBeenCalledTimes(1); }); + it('does not open the dialog over the modal phone context drawer', () => { + TestBed.resetTestingModule(); + const gatedDialog = { open: jest.fn() }; + TestBed.configureTestingModule({ + providers: [ + WorkspaceKeyboardShortcutsService, + { provide: MatDialog, useValue: gatedDialog }, + { + provide: RuntimeCapabilitiesService, + useValue: { isElectron: true }, + }, + { + provide: WorkspaceShellContextDrawerService, + useValue: { isOpen: () => true }, + }, + ], + }); + TestBed.inject(WorkspaceKeyboardShortcutsService); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: '?' })); + + expect(gatedDialog.open).not.toHaveBeenCalled(); + }); + it('does not open while typing in an input', () => { const input = document.createElement('input'); document.body.appendChild(input); diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts index c4d750cf8..63f6f1f2c 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts @@ -11,12 +11,18 @@ import { WorkspaceKeyboardShortcutsDialogComponent, WorkspaceKeyboardShortcutsDialogData, } from './workspace-keyboard-shortcuts-dialog.component'; +import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util'; @Injectable() export class WorkspaceKeyboardShortcutsService { private readonly dialog = inject(MatDialog); private readonly destroyRef = inject(DestroyRef); private readonly runtime = inject(RuntimeCapabilitiesService); + // Root-provided; optional keeps standalone unit tests light. The help key must not open a dialog over the modal phone + // context drawer. + private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, { + optional: true, + }); private readonly onDocumentKeydown = (event: KeyboardEvent): void => this.handleKeydown(event); @@ -72,7 +78,11 @@ export class WorkspaceKeyboardShortcutsService { } private handleKeydown(event: KeyboardEvent): void { - if (isTypingInInput(event) || !isKeyboardShortcutHelpTrigger(event)) { + if ( + isTypingInInput(event) || + !isKeyboardShortcutHelpTrigger(event) || + this.contextDrawer?.isOpen() + ) { return; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.html index 2227a0916..fe2970a92 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.html @@ -1,5 +1,23 @@ + +
+ +
+ @if (variant() === 'sources' && hasPlaylists()) {