From c14b5d5affdc0ba45456f470626497f2050d3030 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 2 May 2026 00:35:37 +0200 Subject: [PATCH] fix(xtream): search all vod and series items Entire-Checkpoint: 3f453cc0085a --- .plans/2026-05-01-xtream-type-search.md | 35 +++++++ .../features/with-selection.feature.spec.ts | 93 ++++++++++++++++++- .../stores/features/with-selection.feature.ts | 19 +++- 3 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 .plans/2026-05-01-xtream-type-search.md diff --git a/.plans/2026-05-01-xtream-type-search.md b/.plans/2026-05-01-xtream-type-search.md new file mode 100644 index 000000000..9a2c7181a --- /dev/null +++ b/.plans/2026-05-01-xtream-type-search.md @@ -0,0 +1,35 @@ +# Fix Xtream Type-Level VOD/Series Search + +## Summary + +- Root cause: `withSelection()` receives the `q` query through the Xtream catalog facade, but `filteredAndSortedContent` returns `sortedContent()` immediately when `selectedCategoryId` is null, so `categorySearchTerm` is ignored on all-items VOD/series routes. +- Fix the store selector so VOD and series search terms filter the current type even when no category is selected. +- No public API, route, schema, or Electron IPC contract changes are needed. + +## Key Changes + +- Update `libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts` so: + - `vod` and `series` apply `filterBySearchTerm` whether `selectedCategoryId` is null or set. + - Existing selected-category behavior, sorting, pagination reset, and `live` behavior stay unchanged. + - Empty search terms still return the full sorted current type. +- Update `libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts` with regression coverage for: + - VOD all-items search with `selectedCategoryId === null`. + - Series all-items search with `selectedCategoryId === null`, ideally across multiple categories. + - Existing category-scoped search remains covered and unchanged. + +## Test Plan + +- Run `pnpm nx test portal-xtream-data-access`. +- Run `pnpm nx test portal-xtream-feature` to confirm the shared catalog route/facade integration still passes. +- Run `pnpm nx build web` as the Angular/Nx build validation. +- Manual Electron check: + - Start `pnpm run serve:backend`. + - Load agent-browser guidance with `agent-browser skills get core` and `agent-browser skills get electron`. + - Attach using `agent-browser --cdp 9222 snapshot -i`. + - Verify an Xtream playlist can search from `/vod?q=...` and `/series?q=...` with no category selected, and still search inside a selected category. + +## Assumptions + +- Scope is limited to Xtream VOD/series catalog search, not the separate global search page. +- Documentation updates are not expected because this is a regression fix for existing behavior; reassess after implementation. +- Finalized plan file target: `.plans/2026-05-01-xtream-type-search.md`. diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts index ad797e478..d7676347c 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts @@ -18,6 +18,12 @@ const TestSelectionStore = signalStore( category_name: 'Movies', type: 'vod', }, + { + id: 20, + category_id: '20', + category_name: 'Documentaries', + type: 'vod', + }, ], vodStreams: [ { @@ -44,9 +50,59 @@ const TestSelectionStore = signalStore( title: 'Fourth', added: '1', }, + { + xtream_id: 5, + category_id: '20', + title: 'First Contact', + added: '5', + }, + { + xtream_id: 6, + category_id: '20', + title: 'Cosmos', + added: '6', + }, + ], + serialCategories: [ + { + id: 30, + category_id: '30', + category_name: 'Sci-Fi', + type: 'series', + }, + { + id: 40, + category_id: '40', + category_name: 'Drama', + type: 'series', + }, + ], + serialStreams: [ + { + xtream_id: 101, + category_id: '30', + title: 'Stargate SG-1', + last_modified: '10', + }, + { + xtream_id: 102, + category_id: '30', + title: 'The Expanse', + last_modified: '9', + }, + { + xtream_id: 103, + category_id: '40', + title: 'Stargate Atlantis', + last_modified: '8', + }, + { + xtream_id: 104, + category_id: '40', + title: 'The Wire', + last_modified: '7', + }, ], - serialCategories: [], - serialStreams: [], }), withSelection() ); @@ -96,4 +152,37 @@ describe('withSelection', () => { 'First', ]); }); + + it('filters all VOD items when no category is selected', () => { + store.setSelectedContentType('vod'); + store.setSelectedCategory(null); + + store.setCategorySearchTerm('first'); + + expect( + store.selectItemsFromSelectedCategory().map((item) => item.title) + ).toEqual(['First Contact', 'First']); + }); + + it('filters all series items across categories when no category is selected', () => { + store.setSelectedContentType('series'); + store.setSelectedCategory(null); + + store.setCategorySearchTerm('stargate'); + + expect( + store.selectItemsFromSelectedCategory().map((item) => item.title) + ).toEqual(['Stargate SG-1', 'Stargate Atlantis']); + }); + + it('keeps VOD search scoped to the selected category', () => { + store.setSelectedContentType('vod'); + store.setSelectedCategory(10); + + store.setCategorySearchTerm('first'); + + expect( + store.selectItemsFromSelectedCategory().map((item) => item.title) + ).toEqual(['First']); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts index 4e139b1f0..fe2fcfb33 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts @@ -268,17 +268,26 @@ export function withSelection() { ? storeAny.vodStreams?.() || [] : storeAny.serialStreams?.() || []; + if (categoryType === 'vod' || categoryType === 'series') { + let filtered = categoryId + ? content.filter( + (item) => Number(item.category_id) === categoryId + ) + : sortedContent(); + + filtered = filterBySearchTerm(filtered, searchTerm); + return categoryId + ? sortByMode(filtered, sortMode, categoryType) + : filtered; + } + if (!categoryId) { return sortedContent(); } - let filtered = content.filter( + const filtered = content.filter( (item) => Number(item.category_id) === categoryId ); - if (categoryType === 'vod' || categoryType === 'series') { - filtered = filterBySearchTerm(filtered, searchTerm); - filtered = sortByMode(filtered, sortMode, categoryType); - } return filtered; });