mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: enhance playlist management by adding optional title input and updating URL submission logic
This commit is contained in:
1 parent
5fdd999c7e
commit
76e4e1b032
12 files changed
+424
-122
No files matched your search
@@ -185,6 +185,8 @@ export class PwaService extends DataService {
|
||||
* @param payload playlist payload
|
||||
*/
|
||||
fetchFromUrl(payload: Partial<Playlist>): 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,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
+2
-2
@@ -42,7 +42,7 @@
|
||||
/>
|
||||
}
|
||||
@case ('url') {
|
||||
<app-url-upload (urlAdded)="sendPlaylistsUrl($event)" />
|
||||
<app-url-upload />
|
||||
}
|
||||
@case ('text') {
|
||||
<app-text-import (textAdded)="uploadAsText($event)" />
|
||||
@@ -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 }}
|
||||
</button>
|
||||
|
||||
+105
@@ -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();
|
||||
});
|
||||
});
|
||||
+18
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,14 @@
|
||||
formControlName="playlistUrl"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<input
|
||||
type="text"
|
||||
matInput
|
||||
[placeholder]="'HOME.XTREAM_PLAYLIST.TITLE' | translate"
|
||||
formControlName="playlistName"
|
||||
/>
|
||||
</mat-form-field>
|
||||
@if (!isDesktop) {
|
||||
<p>
|
||||
{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string> = new EventEmitter();
|
||||
|
||||
form: FormGroup;
|
||||
form!: FormGroup<{
|
||||
playlistName: FormControl<string>;
|
||||
playlistUrl: FormControl<string>;
|
||||
}>;
|
||||
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: [''],
|
||||
});
|
||||
}
|
||||
}
|
||||
+27
-2
@@ -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<CategoryContentViewComponent>;
|
||||
const paramMap$ = new ReplaySubject(1);
|
||||
const queryParamMap$ = new ReplaySubject(1);
|
||||
const isPaginatedContentLoading = signal(true);
|
||||
const categoryItemCount = signal(0);
|
||||
const contentSortMode = signal<PortalCatalogSortMode | null>(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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Record<string, unknown> | null>({
|
||||
id: 11,
|
||||
name: 'Movies',
|
||||
});
|
||||
const selectedCategoryId = signal<number | null>(11);
|
||||
const paginatedContent = signal<Record<string, unknown>[]>([
|
||||
{ xtream_id: 1, title: 'A' },
|
||||
]);
|
||||
const selectedCategoryItems = signal<Record<string, unknown>[]>([
|
||||
{ xtream_id: 1, title: 'A' },
|
||||
{ xtream_id: 2, title: 'B' },
|
||||
]);
|
||||
const selectedItem = signal<Record<string, unknown> | null>(null);
|
||||
const totalPages = signal(1);
|
||||
const isPaginatedContentLoading = signal(false);
|
||||
const contentSortMode = signal<PortalCatalogSortMode>('date-desc');
|
||||
const currentPlaylist = signal<XtreamPlaylistData | null>(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<string, unknown> | 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> & {
|
||||
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<PortalCatalogPlaylistMeta | null>(() => {
|
||||
@@ -160,7 +86,7 @@ export class XtreamCatalogFacadeService
|
||||
}
|
||||
|
||||
setSearchQuery(query: string): void {
|
||||
this.routeSearchQuery.set(query);
|
||||
this.xtreamStore.setCategorySearchTerm(query);
|
||||
}
|
||||
|
||||
setPage(page: number): void {
|
||||
|
||||
+11
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user