feat: enhance playlist management by adding optional title input and updating URL submission logic

This commit is contained in:
4gray committed 2026-04-06 17:57:04 +02:00
1 parent 5fdd999c7e
commit 76e4e1b032
12 files changed
+424 -122

No files matched your search

+11 -1
View File
@@ -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,
})
);
});
@@ -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>
@@ -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();
});
});
@@ -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: [''],
});
}
}
@@ -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 {
@@ -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();