diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 3793c5716..3f7a26c4a 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -185,6 +185,8 @@ export class PwaService extends DataService { * @param payload playlist payload */ fetchFromUrl(payload: Partial): void { + const title = payload.title?.trim() || undefined; + this.getPlaylistFromUrl(payload.url) .pipe( catchError((error) => { @@ -199,10 +201,18 @@ export class PwaService extends DataService { }) ) .subscribe((response: Playlist) => { + const playlist = title + ? { + ...response, + filename: title, + title, + } + : response; + this.store.dispatch( PlaylistActions.handleAddingPlaylistByUrl({ isTemporary: !!payload?.isTemporary, - playlist: response, + playlist, }) ); }); diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html index d2d2debca..076018e6a 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html @@ -42,7 +42,7 @@ /> } @case ('url') { - + } @case ('text') { @@ -63,7 +63,7 @@ mat-button type="button" [disabled]="!urlUpload()?.form?.valid" - (click)="urlUpload()?.urlAdded?.emit(urlUpload()?.form?.value?.playlistUrl)" + (click)="submitUrlPlaylist()" > {{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }} diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts new file mode 100644 index 000000000..80e097fcc --- /dev/null +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts @@ -0,0 +1,105 @@ +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { DataService } from 'services'; +import { PLAYLIST_PARSE_BY_URL } from 'shared-interfaces'; +import { AddPlaylistDialogComponent } from './add-playlist-dialog.component'; + +describe('AddPlaylistDialogComponent', () => { + let component: AddPlaylistDialogComponent; + let dataService: { sendIpcEvent: jest.Mock }; + let dialogRef: { close: jest.Mock }; + + beforeEach(() => { + dataService = { + sendIpcEvent: jest.fn(), + }; + dialogRef = { + close: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + { + provide: DataService, + useValue: dataService, + }, + { + provide: MatDialogRef, + useValue: dialogRef, + }, + { + provide: Store, + useValue: { + dispatch: jest.fn(), + }, + }, + { + provide: MatSnackBar, + useValue: { + open: jest.fn(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: jest.fn((value: string) => value), + }, + }, + { + provide: MAT_DIALOG_DATA, + useValue: null, + }, + ], + }); + + component = TestBed.runInInjectionContext( + () => new AddPlaylistDialogComponent() + ); + }); + + it('sends a trimmed custom title for URL playlists', () => { + (component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({ + form: { + getRawValue: () => ({ + playlistName: ' My Playlist ', + playlistUrl: ' https://example.com/list.m3u ', + }), + }, + })); + + component.submitUrlPlaylist(); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + PLAYLIST_PARSE_BY_URL, + { + title: 'My Playlist', + url: 'https://example.com/list.m3u', + } + ); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('omits the title when the optional name is blank', () => { + (component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({ + form: { + getRawValue: () => ({ + playlistName: ' ', + playlistUrl: 'https://example.com/list.m3u', + }), + }, + })); + + component.submitUrlPlaylist(); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + PLAYLIST_PARSE_BY_URL, + { + url: 'https://example.com/list.m3u', + } + ); + expect(dialogRef.close).toHaveBeenCalled(); + }); +}); diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts index adeac8169..df7fcce50 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts @@ -15,7 +15,6 @@ import { PlaylistCategory, } from '@iptvnator/workspace/shell/util'; import { PlaylistActions } from 'm3u-state'; -import { getFilenameFromUrl } from 'm3u-utils'; import { DataService } from 'services'; import { PLAYLIST_PARSE_BY_URL } from 'shared-interfaces'; import { FileUploadComponent } from '../file-upload/file-upload.component'; @@ -110,13 +109,22 @@ export class AddPlaylistDialogComponent { } /** - * Sends url of the playlist to the renderer process - * @param playlistUrl url of the added playlist + * Sends url of the playlist to the renderer process and preserves the + * existing fallback title behavior when the optional name is blank. */ - sendPlaylistsUrl(playlistUrl: string): void { + submitUrlPlaylist(): void { + const formValue = this.urlUpload()?.form?.getRawValue(); + const playlistUrl = formValue?.playlistUrl?.trim(); + + if (!playlistUrl) { + return; + } + + const playlistName = this.normalizeOptionalValue(formValue?.playlistName); + this.dataService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, { - title: getFilenameFromUrl(playlistUrl), url: playlistUrl, + ...(playlistName ? { title: playlistName } : {}), }); this.closeDialog(); } @@ -139,4 +147,9 @@ export class AddPlaylistDialogComponent { closeDialog(): void { this.dialogRef.close(); } + + private normalizeOptionalValue(value?: string | null): string | undefined { + const normalizedValue = value?.trim(); + return normalizedValue ? normalizedValue : undefined; + } } diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.html b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.html index e03bf5c10..64497b2aa 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.html +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.html @@ -7,6 +7,14 @@ formControlName="playlistUrl" /> + + + @if (!isDesktop) {

{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }} diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts index c01df36e1..f810f3b72 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts @@ -1,8 +1,6 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatCardModule } from '@angular/material/card'; -import { MatInputModule } from '@angular/material/input'; -import { By } from '@angular/platform-browser'; +import { MatFormFieldModule } from '@angular/material/form-field'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { TranslateModule } from '@ngx-translate/core'; import { MockModule } from 'ng-mocks'; @@ -16,9 +14,8 @@ describe('UrlUploadComponent', () => { TestBed.configureTestingModule({ imports: [ UrlUploadComponent, - MockModule(MatInputModule), - MockModule(MatCardModule), MockModule(FormsModule), + MockModule(MatFormFieldModule), MockModule(ReactiveFormsModule), TranslateModule.forRoot(), NoopAnimationsModule, @@ -36,27 +33,28 @@ describe('UrlUploadComponent', () => { expect(component).toBeTruthy(); }); - it('submit form with playlist url', waitForAsync(() => { - jest.spyOn(component.urlAdded, 'emit'); - const TEST_URL = 'http://example.org/playlist.m3u'; - const submitButton = - fixture.debugElement.nativeElement.querySelector('button'); + it('accepts an optional playlist name without affecting url validation', () => { + const testUrl = 'http://example.org/playlist.m3u'; - // test input field validation - expect(submitButton.disabled).toBeTruthy(); - component.form.setValue({ playlistUrl: 'wrong url here' }); + component.form.setValue({ + playlistName: ' Custom Playlist ', + playlistUrl: 'wrong url here', + }); fixture.detectChanges(); - expect(submitButton.disabled).toBeTruthy(); - component.form.setValue({ playlistUrl: TEST_URL + '8' }); - fixture.detectChanges(); - expect(submitButton.disabled).toBeFalsy(); - component.form.setValue({ playlistUrl: TEST_URL }); - fixture.detectChanges(); - expect(submitButton.disabled).toBeFalsy(); + expect(component.form.valid).toBeFalsy(); - const form = fixture.debugElement.query(By.css('form')); - form.triggerEventHandler('ngSubmit', null); + component.form.setValue({ + playlistName: '', + playlistUrl: testUrl, + }); + fixture.detectChanges(); + expect(component.form.valid).toBeTruthy(); - expect(component.urlAdded.emit).toHaveBeenCalledWith(TEST_URL); - })); + component.form.setValue({ + playlistName: ' ', + playlistUrl: testUrl, + }); + fixture.detectChanges(); + expect(component.form.valid).toBeTruthy(); + }); }); diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts index 752327d99..a6c1f1690 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts @@ -1,34 +1,42 @@ -import { Component, EventEmitter, inject, OnInit, Output } from '@angular/core'; +import { Component, inject, OnInit } from '@angular/core'; import { FormBuilder, + FormControl, FormGroup, ReactiveFormsModule, Validators, } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; @Component({ selector: 'app-url-upload', templateUrl: './url-upload.component.html', - imports: [MatInputModule, ReactiveFormsModule, TranslatePipe], + imports: [ + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + TranslatePipe, + ], }) export class UrlUploadComponent implements OnInit { private readonly fb = inject(FormBuilder); - /** Emits url string to the parent component on form submit */ - @Output() urlAdded: EventEmitter = new EventEmitter(); - - form: FormGroup; + form!: FormGroup<{ + playlistName: FormControl; + playlistUrl: FormControl; + }>; readonly isDesktop = !!window.electron; - ngOnInit() { + ngOnInit(): void { const urlRegex = '(https?://.*?)'; - this.form = this.fb.group({ + this.form = this.fb.nonNullable.group({ playlistUrl: [ '', [Validators.required, Validators.pattern(urlRegex)], ], + playlistName: [''], }); } } diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts index 3efd82a4a..ab36f82ea 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -7,9 +7,13 @@ import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatPaginatorModule } from '@angular/material/paginator'; import { MatTooltip } from '@angular/material/tooltip'; -import { ActivatedRoute, Router } from '@angular/router'; +import { + ActivatedRoute, + convertToParamMap, + Router, +} from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { of } from 'rxjs'; +import { ReplaySubject, of } from 'rxjs'; import { PORTAL_CATALOG_DETAIL_COMPONENT, PORTAL_CATALOG_FACADE, @@ -54,6 +58,8 @@ class MockDetailComponent {} describe('CategoryContentViewComponent', () => { let fixture: ComponentFixture; + const paramMap$ = new ReplaySubject(1); + const queryParamMap$ = new ReplaySubject(1); const isPaginatedContentLoading = signal(true); const categoryItemCount = signal(0); const contentSortMode = signal(null); @@ -73,6 +79,7 @@ describe('CategoryContentViewComponent', () => { playlist: signal(null), isPaginatedContentLoading, initialize: jest.fn(), + setSearchQuery: jest.fn(), clearSelectedItem: jest.fn(), setPage: jest.fn(), setLimit: jest.fn(), @@ -86,6 +93,9 @@ describe('CategoryContentViewComponent', () => { categoryItemCount.set(0); contentSortMode.set(null); catalog.initialize.mockClear(); + catalog.setSearchQuery.mockClear(); + paramMap$.next(convertToParamMap({})); + queryParamMap$.next(convertToParamMap({})); await TestBed.configureTestingModule({ imports: [CategoryContentViewComponent, NoopAnimationsModule], @@ -127,6 +137,8 @@ describe('CategoryContentViewComponent', () => { { provide: ActivatedRoute, useValue: { + paramMap: paramMap$.asObservable(), + queryParamMap: queryParamMap$.asObservable(), snapshot: { params: {}, }, @@ -172,4 +184,17 @@ describe('CategoryContentViewComponent', () => { 'Fetching playlist data from source...' ); }); + + it('forwards query-param search updates to the catalog facade when supported', () => { + fixture.detectChanges(); + catalog.setSearchQuery.mockClear(); + + queryParamMap$.next( + convertToParamMap({ + q: 'matrix', + }) + ); + + expect(catalog.setSearchQuery).toHaveBeenCalledWith('matrix'); + }); }); 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 f62362be5..3ff704f0a 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 @@ -179,6 +179,7 @@ export function withSelection() { // Memoized sorted content - only recalculates when content/type changes const sortedContent = computed(() => { const categoryType = store.selectedContentType(); + const sortMode = store.contentSortMode(); const storeAny = store as ParentSelectionStoreLike; const content = categoryType === 'live' @@ -187,6 +188,10 @@ export function withSelection() { ? storeAny.vodStreams?.() || [] : storeAny.serialStreams?.() || []; + if (categoryType === 'vod' || categoryType === 'series') { + return sortByMode(content, sortMode, categoryType); + } + return sortByMode(content, 'date-desc', categoryType); }); diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts new file mode 100644 index 000000000..5492aaba8 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts @@ -0,0 +1,193 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + PortalCatalogSortMode, +} from '@iptvnator/portal/shared/util'; +import { + XtreamPlaylistData, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; +import { XtreamCatalogFacadeService } from './xtream-catalog-facade.service'; + +const PLAYLIST_ONE: XtreamPlaylistData = { + id: 'playlist-1', + name: 'Playlist One', + title: 'Playlist One', + serverUrl: 'http://localhost:3000', + username: 'user', + password: 'secret', + type: 'xtream', +}; + +const PLAYLIST_TWO: XtreamPlaylistData = { + ...PLAYLIST_ONE, + id: 'playlist-2', + name: 'Playlist Two', + title: 'Playlist Two', +}; + +describe('XtreamCatalogFacadeService', () => { + let service: XtreamCatalogFacadeService; + const contentType = signal<'live' | 'vod' | 'series'>('vod'); + const limit = signal(25); + const page = signal(0); + const selectedCategory = signal | null>({ + id: 11, + name: 'Movies', + }); + const selectedCategoryId = signal(11); + const paginatedContent = signal[]>([ + { xtream_id: 1, title: 'A' }, + ]); + const selectedCategoryItems = signal[]>([ + { xtream_id: 1, title: 'A' }, + { xtream_id: 2, title: 'B' }, + ]); + const selectedItem = signal | null>(null); + const totalPages = signal(1); + const isPaginatedContentLoading = signal(false); + const contentSortMode = signal('date-desc'); + const currentPlaylist = signal(PLAYLIST_ONE); + + const xtreamStore = { + selectedContentType: contentType, + limit, + page, + getSelectedCategory: selectedCategory, + selectedCategoryId, + getPaginatedContent: paginatedContent, + selectItemsFromSelectedCategory: selectedCategoryItems, + selectedItem, + getTotalPages: totalPages, + isPaginatedContentLoading, + contentSortMode, + currentPlaylist, + loadAllPositions: jest.fn(), + setCategorySearchTerm: jest.fn(), + setSelectedItem: jest.fn((item: Record | null) => { + selectedItem.set(item); + }), + setSelectedCategory: jest.fn((categoryId: number | null) => { + selectedCategoryId.set(categoryId); + }), + setPage: jest.fn((nextPage: number) => { + page.set(nextPage); + }), + setLimit: jest.fn((nextLimit: number) => { + limit.set(nextLimit); + }), + setContentSortMode: jest.fn((mode: PortalCatalogSortMode) => { + contentSortMode.set(mode); + }), + hasSeriesProgress: jest.fn().mockReturnValue(false), + getProgressPercent: jest.fn().mockReturnValue(40), + isWatched: jest.fn().mockReturnValue(false), + }; + + beforeEach(() => { + localStorage.removeItem('xtream-category-sort-mode'); + contentType.set('vod'); + limit.set(25); + page.set(0); + selectedCategory.set({ id: 11, name: 'Movies' }); + selectedCategoryId.set(11); + paginatedContent.set([{ xtream_id: 1, title: 'A' }]); + selectedCategoryItems.set([ + { xtream_id: 1, title: 'A' }, + { xtream_id: 2, title: 'B' }, + ]); + selectedItem.set(null); + totalPages.set(1); + isPaginatedContentLoading.set(false); + contentSortMode.set('date-desc'); + currentPlaylist.set(PLAYLIST_ONE); + + xtreamStore.loadAllPositions.mockClear(); + xtreamStore.setCategorySearchTerm.mockClear(); + xtreamStore.setSelectedItem.mockClear(); + xtreamStore.setSelectedCategory.mockClear(); + xtreamStore.setPage.mockClear(); + xtreamStore.setLimit.mockClear(); + xtreamStore.setContentSortMode.mockClear(); + xtreamStore.hasSeriesProgress.mockClear(); + xtreamStore.getProgressPercent.mockClear(); + xtreamStore.isWatched.mockClear(); + + TestBed.configureTestingModule({ + providers: [ + XtreamCatalogFacadeService, + { + provide: XtreamStore, + useValue: xtreamStore, + }, + ], + }); + + service = TestBed.inject(XtreamCatalogFacadeService); + }); + + it('delegates category search to the Xtream store', () => { + service.setSearchQuery('matrix'); + + expect(xtreamStore.setCategorySearchTerm).toHaveBeenCalledWith( + 'matrix' + ); + }); + + it('exposes store-driven paginated content, total pages, and category counts', () => { + expect(service.paginatedContent()).toEqual([ + { xtream_id: 1, title: 'A' }, + ]); + expect(service.totalPages()).toBe(1); + expect(service.categoryItemCount()).toBe(2); + + paginatedContent.set([ + { xtream_id: 3, title: 'C' }, + { xtream_id: 4, title: 'D' }, + ]); + selectedCategoryItems.set([ + { xtream_id: 3, title: 'C' }, + { xtream_id: 4, title: 'D' }, + { xtream_id: 5, title: 'E' }, + ]); + totalPages.set(4); + + expect(service.paginatedContent()).toEqual([ + { xtream_id: 3, title: 'C' }, + { xtream_id: 4, title: 'D' }, + ]); + expect(service.totalPages()).toBe(4); + expect(service.categoryItemCount()).toBe(3); + }); + + it('restores saved sort mode, sets the selected category, and loads positions once per playlist', () => { + localStorage.setItem('xtream-category-sort-mode', 'name-asc'); + + service.initialize('42'); + service.initialize('77'); + + expect(xtreamStore.setContentSortMode).toHaveBeenCalledWith('name-asc'); + expect(xtreamStore.setSelectedCategory).toHaveBeenLastCalledWith(77); + expect(xtreamStore.loadAllPositions).toHaveBeenCalledTimes(1); + expect(xtreamStore.loadAllPositions).toHaveBeenCalledWith('playlist-1'); + + currentPlaylist.set(PLAYLIST_TWO); + service.initialize('88'); + + expect(xtreamStore.loadAllPositions).toHaveBeenCalledTimes(2); + expect(xtreamStore.loadAllPositions).toHaveBeenLastCalledWith( + 'playlist-2' + ); + }); + + it('persists sort mode changes and delegates them to the store', () => { + service.setContentSortMode('name-desc'); + + expect(xtreamStore.setContentSortMode).toHaveBeenCalledWith( + 'name-desc' + ); + expect(localStorage.getItem('xtream-category-sort-mode')).toBe( + 'name-desc' + ); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts index f1e4912c6..40b81b61e 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts @@ -1,4 +1,4 @@ -import { Provider, Injectable, computed, inject, signal } from '@angular/core'; +import { Provider, Injectable, computed, inject } from '@angular/core'; import { PortalCatalogFacade, PortalCatalogItemProgress, @@ -9,18 +9,6 @@ import { import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; const SORT_STORAGE_KEY = 'xtream-category-sort-mode'; -const COLLATOR = new Intl.Collator(undefined, { - numeric: true, - sensitivity: 'base', -}); - -type XtreamCatalogItem = Record & { - added?: string; - category_id?: number | string; - last_modified?: string; - name?: string; - title?: string; -}; @Injectable() export class XtreamCatalogFacadeService @@ -30,7 +18,6 @@ export class XtreamCatalogFacadeService private readonly xtreamStore = inject(XtreamStore); private savedPageBeforeDetail: number | null = null; private loadedPositionsPlaylistId: string | null = null; - private readonly routeSearchQuery = signal(''); readonly provider = 'xtream' as const; readonly pageSizeOptions = [10, 25, 50, 100] as const; @@ -38,78 +25,17 @@ export class XtreamCatalogFacadeService readonly limit = this.xtreamStore.limit; readonly pageIndex = this.xtreamStore.page; readonly selectedCategory = this.xtreamStore.getSelectedCategory; + readonly paginatedContent = this.xtreamStore.getPaginatedContent; readonly selectedItem = this.xtreamStore.selectedItem; + readonly totalPages = this.xtreamStore.getTotalPages; readonly isPaginatedContentLoading = this.xtreamStore.isPaginatedContentLoading; - private readonly filteredAndSortedContent = computed(() => { - const contentType = this.contentType(); - const selectedCategoryId = this.xtreamStore.selectedCategoryId(); - const sortMode = this.contentSortMode(); - const searchQuery = this.routeSearchQuery().trim().toLocaleLowerCase(); - const content = - contentType === 'live' - ? this.xtreamStore.liveStreams() - : contentType === 'series' - ? this.xtreamStore.serialStreams() - : this.xtreamStore.vodStreams(); - - let items = [...content] as unknown as XtreamCatalogItem[]; - - if (selectedCategoryId !== null) { - items = items.filter( - (item) => Number(item.category_id) === selectedCategoryId - ); - } - - if ( - selectedCategoryId !== null && - (contentType === 'vod' || contentType === 'series') && - searchQuery.length > 0 - ) { - items = items.filter((item) => - String(item.title ?? item.name ?? '') - .toLocaleLowerCase() - .includes(searchQuery) - ); - } - - return items.sort((left, right) => { - const leftDate = - contentType === 'series' - ? Number(left.last_modified ?? left.added ?? 0) - : Number(left.added ?? 0); - const rightDate = - contentType === 'series' - ? Number(right.last_modified ?? right.added ?? 0) - : Number(right.added ?? 0); - - if (sortMode === 'date-asc') { - return leftDate - rightDate; - } - - if (sortMode === 'date-desc') { - return rightDate - leftDate; - } - - const leftTitle = String(left.title ?? left.name ?? ''); - const rightTitle = String(right.title ?? right.name ?? ''); - const byName = COLLATOR.compare(leftTitle, rightTitle); - return sortMode === 'name-desc' ? -byName : byName; - }); - }); - readonly paginatedContent = computed(() => { - const start = this.pageIndex() * this.limit(); - return this.filteredAndSortedContent().slice(start, start + this.limit()); - }); - readonly totalPages = computed(() => - Math.ceil(this.filteredAndSortedContent().length / this.limit()) - ); readonly selectedCategoryTitle = computed(() => { const category = this.selectedCategory(); return String(category?.['name'] ?? category?.['title'] ?? ''); }); - readonly categoryItemCount = computed( - () => this.filteredAndSortedContent().length + readonly categoryItemCount = computed(() => + this.xtreamStore.selectItemsFromSelectedCategory().length ); readonly contentSortMode = this.xtreamStore.contentSortMode; readonly playlist = computed(() => { @@ -160,7 +86,7 @@ export class XtreamCatalogFacadeService } setSearchQuery(query: string): void { - this.routeSearchQuery.set(query); + this.xtreamStore.setCategorySearchTerm(query); } setPage(page: number): void { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index b32664127..26e217a41 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -373,6 +373,17 @@ describe('WorkspaceShellFacade', () => { ); }); + it('applies q to Xtream category search on vod routes', () => { + const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + + facade.currentUrl.set('/workspace/xtreams/pl-1/vod?q=neo'); + (facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute(); + TestBed.flushEffects(); + + expect(xtreamStore.setCategorySearchTerm).toHaveBeenCalledWith('neo'); + expect(xtreamStore.setSearchTerm).not.toHaveBeenCalled(); + }); + it('enables local-filter search on playlist favorites routes', () => { facade.currentUrl.set('/workspace/playlists/pl-1/favorites?q=news'); (facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();