Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"name": "playlist-import-feature",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/playlist/import/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:playlist-import", "type:feature"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -0,0 +1 @@
export * from './lib/add-playlist-dialog/add-playlist-dialog.component';
@@ -0,0 +1,155 @@
<div class="apd">
<h2 mat-dialog-title>
@if (productBranding?.xtreamOnlyUi) {
{{ 'HOME.KIRATV.ADD_PLAYLIST_TITLE' | translate }}
} @else {
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
}
</h2>
<mat-dialog-content>
@if (!productBranding?.xtreamOnlyUi) {
<div class="apd-segmented" role="tablist" aria-label="Playlist category">
@for (opt of categoryOptions; track opt.value) {
<button
type="button"
role="tab"
[attr.aria-selected]="category() === opt.value"
class="apd-segment"
[class.active]="category() === opt.value"
(click)="category.set(opt.value)"
>
{{ opt.label }}
</button>
}
</div>
@if (category() === 'm3u') {
<div
class="apd-segmented apd-segmented--sub"
role="tablist"
aria-label="M3U source"
>
@for (opt of subtypeOptions; track opt.value) {
<button
type="button"
role="tab"
[attr.aria-selected]="m3uSubType() === opt.value"
class="apd-segment"
[class.active]="m3uSubType() === opt.value"
(click)="m3uSubType.set(opt.value)"
>
{{ opt.labelKey | translate }}
</button>
}
</div>
}
}
<div class="apd-form-area">
@switch (playlistType()) {
@case ('file') {
<app-file-upload
(fileRejected)="rejectFile($event)"
(imported)="onFileImported()"
(closeDialog)="closeDialog()"
/>
}
@case ('url') {
<app-url-upload />
}
@case ('text') {
<app-text-import (textAdded)="uploadAsText($event)" />
}
@case ('stalker') {
<app-stalker-portal-import (addClicked)="closeDialog()" />
}
@case ('xtream') {
<app-xtream-code-import (addClicked)="closeDialog()" />
}
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="closeDialog()">
{{ 'CANCEL' | translate }}
</button>
@switch (playlistType()) {
@case ('url') {
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!urlUpload()?.form?.valid"
(click)="submitUrlPlaylist()"
>
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</button>
}
@case ('file') {
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!fileUpload()?.selectedFile()"
(click)="fileUpload()?.confirm()"
>
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</button>
}
@case ('text') {
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!textImport()?.textForm?.valid"
(click)="textImport()?.textAdded?.emit(textImport()?.textForm?.value?.text)"
>
{{ 'HOME.TEXT_IMPORT.BUTTON_LABEL' | translate }}
</button>
}
@case ('xtream') {
<button
mat-stroked-button
type="button"
[disabled]="!xtreamImport()?.form?.valid || xtreamImport()?.isTestingConnection"
(click)="xtreamImport()?.testConnection()"
>
{{
xtreamImport()?.isTestingConnection
? ('HOME.XTREAM_PLAYLIST.TESTING' | translate)
: ('HOME.XTREAM_PLAYLIST.TEST_CONNECTION' | translate)
}}
</button>
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!xtreamImport()?.form?.valid"
(click)="xtreamImport()?.addPlaylist()"
>
{{ 'HOME.XTREAM_PLAYLIST.ADD' | translate }}
</button>
}
@case ('stalker') {
@if (stalkerImport()?.isLoading()) {
<button mat-flat-button color="accent" type="button" disabled>
{{ 'HOME.STALKER_PORTAL.VALIDATING' | translate }}...
</button>
} @else {
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!stalkerImport()?.form?.valid"
(click)="stalkerImport()?.addPlaylist()"
>
{{ 'HOME.STALKER_PORTAL.ADD' | translate }}
</button>
}
}
}
</mat-dialog-actions>
</div>
@@ -0,0 +1,118 @@
.apd {
--apd-border: var(--app-widget-header-border);
--apd-heading: var(--app-heading-color);
--apd-body: var(--app-body-color);
--apd-muted: var(--app-muted-color);
--apd-surface-raised: var(--app-widget-header-bg);
--apd-accent: var(--app-selection-color);
--apd-segment-track: color-mix(in srgb, var(--app-shell-bg) 70%, transparent);
--apd-segment-active: color-mix(
in srgb,
var(--app-widget-header-bg) 70%,
white 14%
);
}
// ─── Segmented control ──────────────────────────────────────────────────────
.apd-segmented {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
background: var(--apd-segment-track);
border-radius: 12px;
padding: 4px;
gap: 2px;
margin-bottom: 14px;
&--sub {
background: color-mix(in srgb, var(--apd-segment-track) 85%, transparent);
}
}
.apd-segment {
appearance: none;
border: 0;
background: transparent;
color: var(--apd-body);
padding: 8px 14px;
border-radius: 9px;
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
line-height: 1.3;
transition:
background-color 160ms ease,
color 160ms ease,
box-shadow 160ms ease;
&:hover:not(.active) {
color: var(--apd-heading);
}
&.active {
background: var(--apd-segment-active);
color: var(--apd-heading);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.25),
inset 0 0 0 1px color-mix(in srgb, white 6%, transparent);
}
&:focus-visible {
outline: 2px solid var(--apd-accent);
outline-offset: 1px;
}
}
// ─── Form area ──────────────────────────────────────────────────────────────
.apd-form-area {
min-height: 0;
form {
display: flex;
flex-direction: column;
gap: 4px;
}
.w-full,
.mat-mdc-form-field {
width: 100%;
}
// Textarea overrides the compact density so multi-line input has room.
textarea.mat-mdc-input-element {
min-height: 140px;
padding-top: 8px;
}
app-text-import .mat-mdc-form-field-infix {
min-height: auto;
}
app-text-import .mat-mdc-form-field {
min-height: 180px;
}
.cors-note {
margin: 6px 0 0;
padding: 10px 12px;
border-radius: 8px;
background: color-mix(in srgb, var(--apd-surface-raised) 50%, transparent);
color: var(--apd-muted);
font-size: 11.5px;
line-height: 1.45;
}
}
// ─── Light-theme segmented control tweak ────────────────────────────────────
// `.dark-theme` is on <body>, so target light mode via `body:not(...)`.
body:not(.dark-theme) .apd {
--apd-segment-track: color-mix(in srgb, var(--app-content-bg) 70%, transparent);
--apd-segment-active: #ffffff;
.apd-segment.active {
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.06),
0 0 0 1px rgba(0, 0, 0, 0.06);
}
}
@@ -0,0 +1,132 @@
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 { PlaylistActions } from 'm3u-state';
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 };
let store: { dispatch: jest.Mock };
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn(),
};
dialogRef = {
close: jest.fn(),
};
store = {
dispatch: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
{
provide: DataService,
useValue: dataService,
},
{
provide: MatDialogRef,
useValue: dialogRef,
},
{
provide: Store,
useValue: store,
},
{
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();
});
it('strips playlist extensions from uploaded file titles', () => {
component.handlePlaylist({
uploadEvent: {
target: {
result: '#EXTM3U',
},
} as unknown as Event,
file: {
name: 'Local Source.m3u',
path: '/tmp/Local Source.m3u',
} as File,
});
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.parsePlaylist({
uploadType: 'FILE',
playlist: '#EXTM3U',
title: 'Local Source',
path: '/tmp/Local Source.m3u',
})
);
expect(dialogRef.close).toHaveBeenCalled();
});
});
@@ -0,0 +1,180 @@
import {
Component,
computed,
inject,
signal,
ViewEncapsulation,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistType } from '@iptvnator/playlist/shared/ui';
import {
M3uSubType,
PlaylistCategory,
} from '@iptvnator/workspace/shell/util';
import { PlaylistActions } from 'm3u-state';
import { DataService, WORKSPACE_PRODUCT_BRANDING } from 'services';
import { PLAYLIST_PARSE_BY_URL } from 'shared-interfaces';
import { FileUploadComponent } from '../file-upload/file-upload.component';
import { StalkerPortalImportComponent } from '../stalker-portal-import/stalker-portal-import.component';
import { TextImportComponent } from '../text-import/text-import.component';
import { UrlUploadComponent } from '../url-upload/url-upload.component';
import { XtreamCodeImportComponent } from '../xtream-code-import/xtream-code-import.component';
interface CategoryOption {
value: PlaylistCategory;
label: string;
}
interface SubtypeOption {
value: M3uSubType;
labelKey: string;
}
@Component({
imports: [
FileUploadComponent,
MatButtonModule,
MatDialogModule,
StalkerPortalImportComponent,
TextImportComponent,
TranslateModule,
UrlUploadComponent,
XtreamCodeImportComponent,
],
selector: 'app-add-playlist',
templateUrl: './add-playlist-dialog.component.html',
styleUrl: './add-playlist-dialog.component.scss',
encapsulation: ViewEncapsulation.None,
})
export class AddPlaylistDialogComponent {
private dataService = inject(DataService);
private dialogRef = inject(MatDialogRef<AddPlaylistDialogComponent>);
private store = inject(Store);
private snackBar = inject(MatSnackBar);
private translateService = inject(TranslateService);
private data = inject<{ type?: PlaylistType } | null>(MAT_DIALOG_DATA, {
optional: true,
});
readonly productBranding = inject(WORKSPACE_PRODUCT_BRANDING, {
optional: true,
});
readonly urlUpload = viewChild(UrlUploadComponent);
readonly fileUpload = viewChild(FileUploadComponent);
readonly textImport = viewChild(TextImportComponent);
readonly xtreamImport = viewChild(XtreamCodeImportComponent);
readonly stalkerImport = viewChild(StalkerPortalImportComponent);
readonly category = signal<PlaylistCategory>('m3u');
readonly m3uSubType = signal<M3uSubType>('url');
readonly categoryOptions: CategoryOption[] = [
{ value: 'm3u', label: 'M3U' },
{ value: 'xtream', label: 'Xtream' },
{ value: 'stalker', label: 'Stalker' },
];
readonly subtypeOptions: SubtypeOption[] = [
{ value: 'url', labelKey: 'HOME.TABS.URL_UPLOAD' },
{ value: 'file', labelKey: 'HOME.TABS.FILE_UPLOAD' },
{ value: 'text', labelKey: 'HOME.TABS.TEXT_IMPORT' },
];
readonly playlistType = computed<PlaylistType>(() => {
const cat = this.category();
if (cat === 'xtream') return 'xtream';
if (cat === 'stalker') return 'stalker';
return this.m3uSubType();
});
constructor() {
if (this.data?.type) {
this.initFromType(this.data.type);
} else if (this.productBranding?.xtreamOnlyUi) {
this.category.set('xtream');
}
}
private initFromType(type: PlaylistType): void {
if (type === 'xtream') {
this.category.set('xtream');
} else if (type === 'stalker') {
this.category.set('stalker');
} else {
this.category.set('m3u');
this.m3uSubType.set(type as M3uSubType);
}
}
/**
* Closes the dialog after a successful file import. The actual parse and
* dispatch happens inside `PlaylistFileImportService` (called from the
* `FileUploadComponent`).
*/
onFileImported(): void {
this.closeDialog();
}
rejectFile(filename: string): void {
this.snackBar.open(
this.translateService.instant('HOME.FILE_UPLOAD.REJECTED', {
filename,
})
);
}
/**
* Sends url of the playlist to the renderer process and preserves the
* existing fallback title behavior when the optional name is blank.
*/
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, {
url: playlistUrl,
...(playlistName ? { title: playlistName } : {}),
});
this.closeDialog();
}
/**
* Sends IPC event to the renderer process to parse playlist
* @param text playlist as string
*/
uploadAsText(playlist: string): void {
this.store.dispatch(
PlaylistActions.parsePlaylist({
uploadType: 'TEXT',
playlist,
title: this.translateService.instant('HOME.IMPORTED_AS_TEXT'),
})
);
this.closeDialog();
}
closeDialog(): void {
this.dialogRef.close();
}
private normalizeOptionalValue(value?: string | null): string | undefined {
const normalizedValue = value?.trim();
return normalizedValue ? normalizedValue : undefined;
}
}
@@ -0,0 +1,46 @@
import {
Directive,
EventEmitter,
HostListener,
Output,
} from '@angular/core';
@Directive({
standalone: true,
selector: '[appDragDropFileUpload]',
})
export class DragDropFileUploadDirective {
@Output() fileDropped = new EventEmitter<FileList>();
@Output() dragStateChanged = new EventEmitter<boolean>();
@HostListener('dragenter', ['$event']) dragEnter(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.dragStateChanged.emit(true);
}
@HostListener('dragover', ['$event']) dragOver(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
this.dragStateChanged.emit(true);
}
@HostListener('dragleave', ['$event']) dragLeave(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.dragStateChanged.emit(false);
}
@HostListener('drop', ['$event']) drop(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.dragStateChanged.emit(false);
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
this.fileDropped.emit(files);
}
}
}
@@ -0,0 +1,53 @@
@if (selectedFile(); as file) {
<div class="file-selected">
<div class="file-selected__icon">
<mat-icon>description</mat-icon>
</div>
<div class="file-selected__meta">
<div class="file-selected__name" [title]="file.name">
{{ file.name }}
</div>
<div class="file-selected__size">{{ formatSize(file.size) }}</div>
</div>
<button
type="button"
class="file-selected__remove"
(click)="clearSelection()"
[attr.aria-label]="'CLEAR' | translate"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else {
<button
type="button"
class="file-drop"
[class.file-drop--dragging]="isDragging()"
appDragDropFileUpload
(fileDropped)="onFilesDropped($event)"
(dragStateChanged)="onDragStateChange($event)"
(click)="openPicker(fileField)"
>
<span class="file-drop__icon">
<mat-icon>file_download</mat-icon>
</span>
<span class="file-drop__title">
{{ 'HOME.FILE_UPLOAD.DRAG_DROP' | translate }}
</span>
<span class="file-drop__subtitle">
{{ 'HOME.FILE_UPLOAD.OR' | translate }}
{{ 'HOME.FILE_UPLOAD.CHOOSE_PLAYLIST' | translate }}
</span>
<span class="file-drop__browse">
{{ 'HOME.FILE_UPLOAD.BROWSE' | translate }}
</span>
</button>
}
<input
type="file"
name="playlist"
#fileField
(change)="onPicked(fileField)"
accept=".m3u,.m3u8"
hidden
/>
@@ -0,0 +1,194 @@
:host {
display: block;
margin: 4px 0 12px;
}
// ─── Dashed drop zone ────────────────────────────────────────────────────────
.file-drop {
width: 100%;
appearance: none;
font: inherit;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 36px 24px 28px;
border-radius: 16px;
border: 1.5px dashed
color-mix(in srgb, var(--app-body-color) 30%, transparent);
background: color-mix(in srgb, var(--app-shell-bg) 55%, transparent);
color: var(--app-heading-color);
transition:
border-color 160ms ease,
background-color 160ms ease,
transform 160ms ease;
&:hover {
border-color: color-mix(
in srgb,
var(--app-selection-color) 55%,
transparent
);
background: color-mix(
in srgb,
var(--app-selection-color) 4%,
var(--app-shell-bg) 70%
);
}
&--dragging {
border-color: var(--app-selection-color);
background: color-mix(
in srgb,
var(--app-selection-color) 10%,
var(--app-shell-bg) 70%
);
transform: scale(1.005);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
}
&__icon {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--app-body-color);
margin-bottom: 6px;
mat-icon {
font-size: 30px;
width: 30px;
height: 30px;
line-height: 30px;
}
}
&__title {
font-size: 15px;
font-weight: 600;
color: var(--app-heading-color);
letter-spacing: -0.005em;
}
&__subtitle {
font-size: 12.5px;
color: var(--app-muted-color);
margin-bottom: 6px;
}
&__browse {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 18px;
border-radius: 9px;
font-size: 13px;
font-weight: 600;
color: var(--app-heading-color);
background: transparent;
box-shadow: inset 0 0 0 1px
color-mix(in srgb, var(--app-body-color) 35%, transparent);
transition: background-color 160ms ease, box-shadow 160ms ease;
}
&:hover &__browse {
background: color-mix(in srgb, var(--app-hover-overlay) 100%, transparent);
box-shadow: inset 0 0 0 1px
color-mix(in srgb, var(--app-body-color) 55%, transparent);
}
}
// ─── Selected-file card ─────────────────────────────────────────────────────
.file-selected {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 14px 14px 16px;
border-radius: 12px;
background: color-mix(in srgb, var(--app-shell-bg) 85%, transparent);
box-shadow: inset 0 0 0 1px var(--app-widget-header-border);
&__icon {
width: 40px;
height: 40px;
flex: 0 0 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(
in srgb,
var(--app-selection-color) 16%,
transparent
);
color: var(--app-selection-color);
mat-icon {
font-size: 22px;
width: 22px;
height: 22px;
line-height: 22px;
}
}
&__meta {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&__name {
font-size: 14px;
font-weight: 600;
color: var(--app-heading-color);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
&__size {
font-size: 12px;
color: var(--app-muted-color);
}
&__remove {
appearance: none;
border: 0;
background: transparent;
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--app-muted-color);
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}
&:hover {
background: var(--app-hover-overlay);
color: var(--app-heading-color);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
}
}
}
@@ -0,0 +1,94 @@
import { Component, EventEmitter, Output, inject, signal } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util';
import { DragDropFileUploadDirective } from './drag-drop-file-upload.directive';
const MB = 1024 * 1024;
const KB = 1024;
@Component({
imports: [DragDropFileUploadDirective, MatIconModule, TranslatePipe],
selector: 'app-file-upload',
templateUrl: './file-upload.component.html',
styleUrls: ['./file-upload.component.scss'],
})
export class FileUploadComponent {
private readonly importService = inject(PlaylistFileImportService);
@Output() imported = new EventEmitter<{ title: string }>();
@Output() fileRejected = new EventEmitter<string>();
@Output() closeDialog = new EventEmitter<void>();
readonly selectedFile = signal<File | null>(null);
readonly isDragging = signal(false);
readonly isImporting = signal(false);
openPicker(input: HTMLInputElement): void {
input.value = '';
input.click();
}
onDragStateChange(isDragging: boolean): void {
this.isDragging.set(isDragging);
}
onFilesDropped(files: FileList): void {
this.setFile(files[0]);
}
onPicked(input: HTMLInputElement): void {
const file = input.files?.[0] as (File & { path?: string }) | undefined;
const pathOverride = input.dataset['filePathOverride'];
if (file && !file.path && pathOverride) {
Object.defineProperty(file, 'path', {
configurable: true,
value: pathOverride,
});
}
if (file) {
this.setFile(file);
}
delete input.dataset['filePathOverride'];
input.value = '';
}
clearSelection(): void {
this.selectedFile.set(null);
}
async confirm(): Promise<void> {
const file = this.selectedFile();
if (!file || this.isImporting()) return;
this.isImporting.set(true);
const result = await this.importService.importFile(file);
this.isImporting.set(false);
if (result.ok === true) {
this.imported.emit({ title: result.title });
return;
}
this.selectedFile.set(null);
this.fileRejected.emit(file.name);
}
formatSize(bytes: number): string {
if (bytes < KB) return `${bytes} B`;
if (bytes < MB) return `${(bytes / KB).toFixed(1)} KB`;
return `${(bytes / MB).toFixed(1)} MB`;
}
private setFile(file: File | undefined): void {
if (!file) return;
if (!this.importService.isSupportedFile(file)) {
this.fileRejected.emit(file.name);
return;
}
this.selectedFile.set(file);
}
}
@@ -0,0 +1,116 @@
<form [formGroup]="form">
<mat-form-field class="w-full">
<mat-label for="title">{{
'HOME.STALKER_PORTAL.TITLE' | translate
}}</mat-label>
<input matInput type="text" id="title" formControlName="title" />
</mat-form-field>
<mat-form-field class="w-full" subscriptSizing="dynamic">
<mat-label for="portalUrl">{{
'HOME.STALKER_PORTAL.SERVER_URL' | translate
}}</mat-label>
<input
matInput
type="text"
id="portalUrl"
formControlName="portalUrl"
/>
<mat-hint>{{
'HOME.STALKER_PORTAL.URL_VALIDATION_ERROR' | translate
}}</mat-hint>
<mat-error>{{ 'SETTINGS.EPG_URL_ERROR' | translate }}</mat-error>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="macAddress">{{
'HOME.STALKER_PORTAL.MAC_ADDRESS' | translate
}}</mat-label>
<input
matInput
type="text"
id="macAddress"
formControlName="macAddress"
/>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="serialNumber">{{
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
}}</mat-label>
<input
matInput
type="text"
id="serialNumber"
formControlName="serialNumber"
/>
<mat-hint>{{
'HOME.STALKER_PORTAL.SERIAL_NUMBER_HINT' | translate
}}</mat-hint>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="deviceId1">{{
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
}}</mat-label>
<input
matInput
type="text"
id="deviceId1"
formControlName="deviceId1"
/>
<mat-hint>{{
'HOME.STALKER_PORTAL.DEVICE_ID_HINT' | translate
}}</mat-hint>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="deviceId2">{{
'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate
}}</mat-label>
<input
matInput
type="text"
id="deviceId2"
formControlName="deviceId2"
/>
<mat-hint>{{
'HOME.STALKER_PORTAL.DEVICE_ID_HINT' | translate
}}</mat-hint>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="signature1">{{
'HOME.STALKER_PORTAL.SIGNATURE_1' | translate
}}</mat-label>
<input
matInput
type="text"
id="signature1"
formControlName="signature1"
/>
<mat-hint>{{
'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate
}}</mat-hint>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="signature2">{{
'HOME.STALKER_PORTAL.SIGNATURE_2' | translate
}}</mat-label>
<input
matInput
type="text"
id="signature2"
formControlName="signature2"
/>
<mat-hint>{{
'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate
}}</mat-hint>
</mat-form-field>
<!-- <mat-form-field class="w-full">
<mat-label for="username">{{
'HOME.XTREAM_PLAYLIST.USERNAME' | translate
}}</mat-label>
<input matInput type="text" id="username" formControlName="username" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="password">{{
'HOME.XTREAM_PLAYLIST.PASSWORD' | translate
}}</mat-label>
<input matInput type="text" id="password" formControlName="password" />
</mat-form-field> -->
</form>
@@ -0,0 +1,240 @@
import { Component, inject, output, signal } from '@angular/core';
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import {
STALKER_SERIAL_NUMBER,
StalkerSessionService,
} from '@iptvnator/portal/stalker/data-access';
import { Playlist } from 'shared-interfaces';
import { v4 as uuid } from 'uuid';
@Component({
imports: [
FormsModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
TranslatePipe,
],
selector: 'app-stalker-portal-import',
templateUrl: './stalker-portal-import.component.html',
styles: [
`
:host {
display: flex;
margin: 10px;
justify-content: center;
}
form {
width: 100%;
}
.loading-container {
display: flex;
align-items: center;
gap: 8px;
}
`,
],
})
export class StalkerPortalImportComponent {
readonly addClicked = output<void>();
readonly URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
readonly form = new FormGroup({
_id: new FormControl(uuid()),
title: new FormControl('', [Validators.required]),
macAddress: new FormControl('', [Validators.required]),
serialNumber: new FormControl(''),
deviceId1: new FormControl(''),
deviceId2: new FormControl(''),
signature1: new FormControl(''),
signature2: new FormControl(''),
password: new FormControl(''),
username: new FormControl(''),
portalUrl: new FormControl('', [
Validators.required,
Validators.pattern(this.URL_REGEX),
]),
importDate: new FormControl(new Date().toISOString()),
userAgent: new FormControl(''),
});
private readonly stalkerSessionService = inject(StalkerSessionService);
private readonly store = inject(Store);
private readonly snackBar = inject(MatSnackBar);
readonly translate = inject(TranslateService);
readonly isLoading = signal(false);
async addPlaylist() {
if (!this.form.valid || this.isLoading()) {
return;
}
this.isLoading.set(true);
try {
const originalUrl = this.form.value.portalUrl;
const transformedUrl = this.transformPortalUrl(originalUrl);
const isFullStalkerPortal =
this.isFullStalkerPortalUrl(originalUrl);
let stalkerToken: string | undefined;
let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined;
let stalkerSerialNumber: string | undefined;
let stalkerDeviceId1: string | undefined;
let stalkerDeviceId2: string | undefined;
let stalkerSignature1: string | undefined;
let stalkerSignature2: string | undefined;
// For full stalker portal URLs, perform handshake and get profile
if (isFullStalkerPortal) {
// Use provided serial number or generate a new one
stalkerSerialNumber =
this.form.value.serialNumber?.trim() ||
STALKER_SERIAL_NUMBER;
// Use provided device IDs if available (64 hex chars each)
stalkerDeviceId1 =
this.form.value.deviceId1?.trim() || undefined;
stalkerDeviceId2 =
this.form.value.deviceId2?.trim() || undefined;
stalkerSignature1 =
this.form.value.signature1?.trim() || undefined;
stalkerSignature2 =
this.form.value.signature2?.trim() || undefined;
try {
const authResult =
await this.stalkerSessionService.authenticate(
transformedUrl,
this.form.value.macAddress,
stalkerSerialNumber,
stalkerDeviceId1,
stalkerDeviceId2
);
stalkerToken = authResult.token;
if (authResult.accountInfo) {
stalkerAccountInfo = {
login: authResult.accountInfo.login,
expireDate: authResult.accountInfo.expire_date,
tariffPlanName:
authResult.accountInfo.tariff_plan_name,
status: authResult.accountInfo.status,
};
}
// Show success notification with account info if available
if (stalkerAccountInfo?.expireDate) {
const expireDate = new Date(
stalkerAccountInfo.expireDate * 1000
);
this.snackBar.open(
`Portal validated. Expires: ${expireDate.toLocaleDateString()}`,
null,
{ duration: 3000 }
);
}
} catch (error) {
console.error(
'[StalkerImport] Authentication failed:',
error
);
this.snackBar.open(
'Failed to authenticate with portal. Please check URL and MAC address.',
null,
{ duration: 5000 }
);
this.isLoading.set(false);
return;
}
}
const playlist: Playlist = {
...this.form.value,
portalUrl: transformedUrl,
isFullStalkerPortal,
stalkerToken,
stalkerAccountInfo,
stalkerSerialNumber,
stalkerDeviceId1,
stalkerDeviceId2,
stalkerSignature1,
stalkerSignature2,
} as Playlist;
this.store.dispatch(PlaylistActions.addPlaylist({ playlist }));
this.addClicked.emit();
} finally {
this.isLoading.set(false);
}
}
/**
* Checks if the URL is a full stalker portal URL that requires handshake authentication
* Pattern: example.com/stalker_portal/c or example.com/stalker_portal/...
*/
isFullStalkerPortalUrl(url: string): boolean {
return url.includes('/stalker_portal');
}
/**
* Transforms the portal URL to the correct API endpoint
* - Simple URL (example.com/c) -> example.com/portal.php
* - Full stalker portal (example.com/stalker_portal/c) -> example.com/stalker_portal/server/load.php
*/
transformPortalUrl(url: string): string {
// Remove trailing slashes
url = url.replace(/\/+$/, '');
// Case 1: Simple URL ending with /c -> convert to /portal.php
if (url.endsWith('/c')) {
// Check if it's a full stalker portal URL
if (url.includes('/stalker_portal')) {
// example.com/stalker_portal/c -> example.com/stalker_portal/server/load.php
return url.replace(
/\/stalker_portal\/c$/,
'/stalker_portal/server/load.php'
);
}
// Simple URL: example.com/c -> example.com/portal.php
return url.replace(/\/c$/, '/portal.php');
}
// Case 2: Full stalker portal URL without /c at the end
if (
url.includes('/stalker_portal') &&
!url.includes('/server/load.php')
) {
// example.com/stalker_portal -> example.com/stalker_portal/server/load.php
if (url.endsWith('/stalker_portal')) {
return url + '/server/load.php';
}
// If it has other path segments after /stalker_portal, append server/load.php
if (!url.endsWith('/load.php')) {
return url.replace(
/\/stalker_portal(\/.*)?$/,
'/stalker_portal/server/load.php'
);
}
}
// Otherwise keep the provided url
return url;
}
}
@@ -0,0 +1,6 @@
<form [formGroup]="textForm">
<mat-form-field class="w-full">
<mat-label>{{ 'HOME.TEXT_IMPORT.LABEL' | translate }}</mat-label>
<textarea formControlName="text" matInput></textarea>
</mat-form-field>
</form>
@@ -0,0 +1,7 @@
button {
margin: 0 15px;
}
textarea {
height: 300px !important;
}
@@ -0,0 +1,34 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { TranslateModule } from '@ngx-translate/core';
import { MockModule } from 'ng-mocks';
import { TextImportComponent } from './text-import.component';
describe('TextImportComponent', () => {
let component: TextImportComponent;
let fixture: ComponentFixture<TextImportComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
TextImportComponent,
MockModule(MatInputModule),
MockModule(FormsModule),
MockModule(ReactiveFormsModule),
TranslateModule.forRoot(),
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TextImportComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,23 @@
import { Component, EventEmitter, Output } from '@angular/core';
import {
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
@Component({
selector: 'app-text-import',
templateUrl: './text-import.component.html',
styleUrls: ['./text-import.component.scss'],
imports: [MatInputModule, ReactiveFormsModule, TranslatePipe],
})
export class TextImportComponent {
@Output() textAdded = new EventEmitter<string>();
textForm = new FormGroup({
text: new FormControl('', Validators.required),
});
}
@@ -0,0 +1,15 @@
<form [formGroup]="form">
<mat-form-field class="w-full">
<mat-label>{{ 'HOME.URL_UPLOAD.PLAYLIST_URL' | translate }}</mat-label>
<input type="url" matInput formControlName="playlistUrl" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{ 'HOME.XTREAM_PLAYLIST.TITLE' | translate }}</mat-label>
<input type="text" matInput formControlName="playlistName" />
</mat-form-field>
@if (!isDesktop) {
<p class="cors-note">
{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }}
</p>
}
</form>
@@ -0,0 +1,60 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
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';
import { UrlUploadComponent } from './url-upload.component';
describe('UrlUploadComponent', () => {
let component: UrlUploadComponent;
let fixture: ComponentFixture<UrlUploadComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
UrlUploadComponent,
MockModule(FormsModule),
MockModule(MatFormFieldModule),
MockModule(ReactiveFormsModule),
TranslateModule.forRoot(),
NoopAnimationsModule,
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(UrlUploadComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('accepts an optional playlist name without affecting url validation', () => {
const testUrl = 'http://example.org/playlist.m3u';
component.form.setValue({
playlistName: ' Custom Playlist ',
playlistUrl: 'wrong url here',
});
fixture.detectChanges();
expect(component.form.valid).toBeFalsy();
component.form.setValue({
playlistName: '',
playlistUrl: testUrl,
});
fixture.detectChanges();
expect(component.form.valid).toBeTruthy();
component.form.setValue({
playlistName: ' ',
playlistUrl: testUrl,
});
fixture.detectChanges();
expect(component.form.valid).toBeTruthy();
});
});
@@ -0,0 +1,42 @@
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: [
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
TranslatePipe,
],
})
export class UrlUploadComponent implements OnInit {
private readonly fb = inject(FormBuilder);
form!: FormGroup<{
playlistName: FormControl<string>;
playlistUrl: FormControl<string>;
}>;
readonly isDesktop = !!window.electron;
ngOnInit(): void {
const urlRegex = '(https?://.*?)';
this.form = this.fb.nonNullable.group({
playlistUrl: [
'',
[Validators.required, Validators.pattern(urlRegex)],
],
playlistName: [''],
});
}
}
@@ -0,0 +1,56 @@
<form [formGroup]="form">
@if (!productBranding?.xtreamDefaultServerUrl) {
<mat-form-field class="w-full">
<mat-label for="title">{{
'HOME.XTREAM_PLAYLIST.TITLE' | translate
}}</mat-label>
<input matInput type="text" id="title" formControlName="title" />
</mat-form-field>
<mat-form-field class="w-full" subscriptSizing="dynamic">
<mat-label for="serverUrl">{{
'HOME.XTREAM_PLAYLIST.SERVER_URL' | translate
}}</mat-label>
<input
matInput
type="text"
id="serverUrl"
formControlName="serverUrl"
(input)="extractParams(form.value.serverUrl)"
/>
<mat-hint>{{
'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR' | translate
}}</mat-hint>
<mat-error>{{ 'SETTINGS.EPG_URL_ERROR' | translate }}</mat-error>
</mat-form-field>
} @else {
<p class="kiratv-fixed-server" id="kiratv-fixed-server">
{{ 'HOME.KIRATV.FIXED_SERVER' | translate }}
<strong>{{ productBranding.xtreamDefaultServerUrl }}</strong>
</p>
<p class="kiratv-fixed-title" id="kiratv-fixed-title">
{{ 'HOME.KIRATV.FIXED_PLAYLIST_TITLE' | translate }}
<strong>{{ productBranding.defaultPlaylistTitle }}</strong>
</p>
}
<mat-form-field class="w-full">
<mat-label for="username">{{
'HOME.XTREAM_PLAYLIST.USERNAME' | translate
}}</mat-label>
<input matInput type="text" id="username" formControlName="username" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="password">{{
'HOME.XTREAM_PLAYLIST.PASSWORD' | translate
}}</mat-label>
<input matInput type="text" id="password" formControlName="password" />
</mat-form-field>
@if (connectionStatus) {
<div class="connection-status">
<mat-icon [class]="getStatusClass()">
{{ getStatusIcon() }}
</mat-icon>
<span [class]="getStatusClass()">{{ getStatusMessage() }}</span>
</div>
}
</form>
@@ -0,0 +1,214 @@
import {
Component,
EventEmitter,
OnInit,
Output,
inject,
} from '@angular/core';
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import {
PortalStatus,
PortalStatusService,
WORKSPACE_PRODUCT_BRANDING,
} from 'services';
import { Playlist } from 'shared-interfaces';
import { v4 as uuid } from 'uuid';
@Component({
imports: [
FormsModule,
MatFormFieldModule,
MatIcon,
MatInputModule,
ReactiveFormsModule,
TranslatePipe,
],
selector: 'app-xtream-code-import',
templateUrl: './xtream-code-import.component.html',
styles: [
`
:host {
display: flex;
margin: 10px;
justify-content: center;
}
form {
width: 100%;
}
.status-active {
color: #4caf50;
}
.status-inactive {
color: #f44336;
}
.status-expired {
color: #ff9800;
}
.status-unavailable {
color: #9e9e9e;
}
.connection-status {
margin: 10px 0;
display: flex;
align-items: center;
gap: 8px;
}
.kiratv-fixed-server,
.kiratv-fixed-title {
margin: 0 0 12px;
font-size: 14px;
line-height: 1.4;
}
`,
],
})
export class XtreamCodeImportComponent implements OnInit {
@Output() addClicked = new EventEmitter<void>();
URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
readonly productBranding = inject(WORKSPACE_PRODUCT_BRANDING, {
optional: true,
});
form = new FormGroup({
_id: new FormControl(uuid()),
title: new FormControl('', [Validators.required]),
password: new FormControl('', [Validators.required]),
username: new FormControl('', [Validators.required]),
serverUrl: new FormControl('', [
Validators.required,
Validators.pattern(this.URL_REGEX),
]),
importDate: new FormControl(new Date().toISOString()),
});
readonly store = inject(Store);
readonly portalStatusService = inject(PortalStatusService);
connectionStatus: PortalStatus | null = null;
isTestingConnection = false;
ngOnInit(): void {
this.applyProductBrandingDefaults();
}
private applyProductBrandingDefaults(): void {
const b = this.productBranding;
if (!b?.xtreamDefaultServerUrl) {
return;
}
this.form.patchValue({
serverUrl: b.xtreamDefaultServerUrl,
title: b.defaultPlaylistTitle,
});
this.form.get('serverUrl')?.disable({ emitEvent: false });
this.form.get('title')?.disable({ emitEvent: false });
}
async testConnection(): Promise<void> {
if (!this.form.valid) return;
this.isTestingConnection = true;
const serverUrlAsString = this.form.getRawValue().serverUrl as string;
const url = new URL(serverUrlAsString);
const serverUrl = `${url.protocol}//${url.hostname}${
url.port ? ':' + url.port : ''
}`;
try {
this.connectionStatus =
await this.portalStatusService.checkPortalStatus(
serverUrl,
this.form.getRawValue().username as string,
this.form.getRawValue().password as string
);
} finally {
this.isTestingConnection = false;
}
}
getStatusMessage(): string {
return this.portalStatusService.getStatusMessage(this.connectionStatus);
}
getStatusClass(): string {
return this.portalStatusService.getStatusClass(this.connectionStatus);
}
getStatusIcon(): string {
return this.portalStatusService.getStatusIcon(this.connectionStatus);
}
clearForm() {
const b = this.productBranding;
this.form.reset({
_id: uuid(),
title: b?.defaultPlaylistTitle ?? '',
password: '',
username: '',
serverUrl: b?.xtreamDefaultServerUrl ?? '',
importDate: new Date().toISOString(),
});
this.applyProductBrandingDefaults();
this.connectionStatus = null;
}
addPlaylist() {
const raw = this.form.getRawValue();
const serverUrlAsString = raw.serverUrl as string;
const url = new URL(serverUrlAsString);
const serverUrl = `${url.protocol}//${url.hostname}${
url.port ? ':' + url.port : ''
}`;
this.store.dispatch(
PlaylistActions.addPlaylist({
playlist: {
...raw,
serverUrl,
} as Playlist,
})
);
this.addClicked.emit();
}
extractParams(urlAsString: string): void {
if (
this.form.get('username').value !== '' ||
this.form.get('password').value !== ''
)
return;
try {
// Create a new URL object from the complete link
const url = new URL(urlAsString);
// Extract username and password from query parameters
const username = url.searchParams.get('username') || '';
const password = url.searchParams.get('password') || '';
this.form.get('username')?.setValue(username);
this.form.get('password')?.setValue(password);
} catch (error) {
console.error('Invalid URL', error);
}
}
}