Proyecto App Linux
This commit is contained in:
@@ -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';
|
||||
+155
@@ -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>
|
||||
+118
@@ -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);
|
||||
}
|
||||
}
|
||||
+132
@@ -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();
|
||||
});
|
||||
});
|
||||
+180
@@ -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);
|
||||
}
|
||||
}
|
||||
+116
@@ -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>
|
||||
+240
@@ -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: [''],
|
||||
});
|
||||
}
|
||||
}
|
||||
+56
@@ -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>
|
||||
+214
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user