feat(settings): secure playlist backup credentials

This commit is contained in:
4gray committed 2026-07-27 10:15:27 +02:00
1 parent 04e9027840
commit 6726863081
27 files changed
+874 -4

No files matched your search

@@ -0,0 +1,105 @@
<h2 mat-dialog-title>
{{ 'SETTINGS.BACKUP_CREDENTIALS_DIALOG.TITLE' | translate }}
</h2>
<form
class="settings-backup-credentials-dialog"
[formGroup]="credentialsForm"
(ngSubmit)="submit()"
novalidate
>
<mat-dialog-content>
<p class="settings-backup-credentials-dialog__lead">
{{ 'SETTINGS.BACKUP_CREDENTIALS_DIALOG.MESSAGE' | translate }}
</p>
<dl class="settings-backup-credentials-dialog__context">
<div>
<dt>
{{
'SETTINGS.BACKUP_CREDENTIALS_DIALOG.PLAYLIST'
| translate
}}
</dt>
<dd>{{ dialogData.playlistTitle }}</dd>
</div>
@if (dialogData.serverHost) {
<div>
<dt>
{{
'SETTINGS.BACKUP_CREDENTIALS_DIALOG.SERVER'
| translate
}}
</dt>
<dd>{{ dialogData.serverHost }}</dd>
</div>
}
</dl>
<div class="settings-backup-credentials-dialog__fields">
<mat-form-field appearance="outline">
<mat-label>
{{
'SETTINGS.BACKUP_CREDENTIALS_DIALOG.USERNAME'
| translate
}}
</mat-label>
<input
matInput
formControlName="username"
autocomplete="username"
cdkFocusInitial
/>
@if (
credentialsForm.controls.username.invalid &&
credentialsForm.controls.username.touched
) {
<mat-error>
{{
'SETTINGS.BACKUP_CREDENTIALS_DIALOG.USERNAME_REQUIRED'
| translate
}}
</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>
{{
'SETTINGS.BACKUP_CREDENTIALS_DIALOG.PASSWORD'
| translate
}}
</mat-label>
<input
matInput
type="password"
formControlName="password"
autocomplete="current-password"
/>
@if (
credentialsForm.controls.password.invalid &&
credentialsForm.controls.password.touched
) {
<mat-error>
{{
'SETTINGS.BACKUP_CREDENTIALS_DIALOG.PASSWORD_REQUIRED'
| translate
}}
</mat-error>
}
</mat-form-field>
</div>
</mat-dialog-content>
<mat-dialog-actions
align="end"
class="settings-backup-credentials-dialog__actions"
>
<button mat-button type="button" (click)="skip()">
{{ 'SETTINGS.BACKUP_CREDENTIALS_DIALOG.SKIP' | translate }}
</button>
<button mat-flat-button type="submit">
{{ 'SETTINGS.BACKUP_CREDENTIALS_DIALOG.IMPORT' | translate }}
</button>
</mat-dialog-actions>
</form>
@@ -0,0 +1,55 @@
:host {
display: block;
}
.settings-backup-credentials-dialog {
min-width: min(100%, 380px);
}
.settings-backup-credentials-dialog__lead {
margin: 0;
line-height: 1.55;
text-wrap: pretty;
}
.settings-backup-credentials-dialog__context {
display: grid;
gap: 8px;
margin: 18px 0;
padding: 12px 14px;
border: 1px solid
color-mix(in srgb, var(--mat-sys-outline) 42%, transparent);
border-radius: 14px;
background: color-mix(
in srgb,
var(--mat-sys-surface-container) 84%,
transparent
);
div {
display: grid;
grid-template-columns: minmax(72px, auto) minmax(0, 1fr);
gap: 12px;
}
dt {
color: var(--mat-sys-on-surface-variant);
font-size: 0.8rem;
font-weight: 700;
}
dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
}
.settings-backup-credentials-dialog__fields {
display: grid;
gap: 4px;
}
.settings-backup-credentials-dialog__actions {
padding-top: 2px;
}
@@ -0,0 +1,118 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
SettingsBackupCredentialsDialogComponent,
SettingsBackupCredentialsDialogData,
} from './settings-backup-credentials-dialog.component';
describe('SettingsBackupCredentialsDialogComponent', () => {
let component: SettingsBackupCredentialsDialogComponent;
let fixture: ComponentFixture<SettingsBackupCredentialsDialogComponent>;
let dialogRef: { close: jest.Mock };
beforeEach(async () => {
dialogRef = {
close: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [
SettingsBackupCredentialsDialogComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: {
playlistTitle: 'Living room',
serverHost: 'provider.example:8443',
} satisfies SettingsBackupCredentialsDialogData,
},
{
provide: MatDialogRef,
useValue: dialogRef,
},
],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation(
'en',
{
SETTINGS: {
BACKUP_CREDENTIALS_DIALOG: {
TITLE: 'Xtream credentials required',
MESSAGE:
'This redacted backup does not contain the credentials for this playlist.',
PLAYLIST: 'Playlist',
SERVER: 'Server',
USERNAME: 'Username',
PASSWORD: 'Password',
USERNAME_REQUIRED: 'Enter a username.',
PASSWORD_REQUIRED: 'Enter a password.',
SKIP: 'Skip playlist',
IMPORT: 'Import playlist',
},
},
},
true
);
translate.use('en');
fixture = TestBed.createComponent(
SettingsBackupCredentialsDialogComponent
);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('identifies the redacted playlist without exposing a full source URL', () => {
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
expect(text).toContain('Xtream credentials required');
expect(text).toContain('Living room');
expect(text).toContain('provider.example:8443');
});
it('marks blank credentials as invalid and keeps the dialog open', () => {
component.credentialsForm.setValue({
username: ' ',
password: ' ',
});
component.submit();
fixture.detectChanges();
expect(component.credentialsForm.invalid).toBe(true);
expect(component.credentialsForm.controls.username.touched).toBe(true);
expect(component.credentialsForm.controls.password.touched).toBe(true);
expect(dialogRef.close).not.toHaveBeenCalled();
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
expect(text).toContain('Enter a username.');
expect(text).toContain('Enter a password.');
});
it('returns a trimmed username while preserving the password exactly', () => {
component.credentialsForm.setValue({
username: ' viewer ',
password: ' secret with spaces ',
});
component.submit();
expect(dialogRef.close).toHaveBeenCalledWith({
username: 'viewer',
password: ' secret with spaces ',
});
});
it('returns null when the playlist is skipped', () => {
component.skip();
expect(dialogRef.close).toHaveBeenCalledWith(null);
});
});
@@ -0,0 +1,70 @@
import { Component, inject } from '@angular/core';
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { PlaylistBackupXtreamCredentials } from '@iptvnator/services';
import { TranslateModule } from '@ngx-translate/core';
export interface SettingsBackupCredentialsDialogData {
playlistTitle: string;
serverHost: string;
}
@Component({
selector: 'app-settings-backup-credentials-dialog',
templateUrl: './settings-backup-credentials-dialog.component.html',
styleUrls: ['./settings-backup-credentials-dialog.component.scss'],
imports: [
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
TranslateModule,
],
})
export class SettingsBackupCredentialsDialogComponent {
private readonly dialogRef =
inject<
MatDialogRef<
SettingsBackupCredentialsDialogComponent,
PlaylistBackupXtreamCredentials | null
>
>(MatDialogRef);
private readonly formBuilder = inject(NonNullableFormBuilder);
readonly dialogData =
inject<SettingsBackupCredentialsDialogData>(MAT_DIALOG_DATA);
readonly credentialsForm = this.formBuilder.group({
username: ['', [Validators.required, Validators.pattern(/\S/)]],
password: ['', [Validators.required, Validators.pattern(/\S/)]],
});
submit(): void {
if (this.credentialsForm.invalid) {
this.credentialsForm.markAllAsTouched();
return;
}
const { username, password } = this.credentialsForm.getRawValue();
this.dialogRef.close({
username: username.trim(),
password,
});
}
skip(): void {
this.dialogRef.close(null);
}
}
@@ -63,6 +63,37 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.BACKUP_CONTENTS' | translate }}</h4>
<p id="backup-redacted-warning">
{{ 'SETTINGS.BACKUP_REDACTED_WARNING' | translate }}
</p>
@if (includeSecrets()) {
<p
id="backup-secrets-warning"
class="settings-backup-warning--secrets"
>
{{ 'SETTINGS.BACKUP_SECRETS_WARNING' | translate }}
</p>
}
</div>
<div class="setting-item__control">
<mat-checkbox
data-test-id="backup-include-secrets"
[checked]="includeSecrets()"
[attr.aria-describedby]="
includeSecrets()
? 'backup-redacted-warning backup-secrets-warning'
: 'backup-redacted-warning'
"
(change)="includeSecrets.set($event.checked)"
>
{{ 'SETTINGS.BACKUP_INCLUDE_SECRETS' | translate }}
</mat-checkbox>
</div>
</div>
@if (isPwa()) {
<div class="settings-note">
{{ 'SETTINGS.EPG_NOTE' | translate }}
@@ -1,26 +1,49 @@
import { Component, input, output, ViewEncapsulation } from '@angular/core';
import {
Component,
inject,
input,
output,
ViewEncapsulation,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsBackupFacade } from './settings-backup.facade';
@Component({
selector: 'app-settings-backup-section',
imports: [
MatButtonModule,
MatCheckboxModule,
MatIconModule,
MatProgressSpinnerModule,
TranslateModule,
],
templateUrl: './settings-backup-section.component.html',
encapsulation: ViewEncapsulation.None,
styles: [':host { display: contents; }'],
styles: [
`
:host {
display: contents;
}
.settings-backup-warning--secrets {
color: var(--mat-sys-error);
font-weight: 600;
}
`,
],
})
export class SettingsBackupSectionComponent {
private readonly backupFacade = inject(SettingsBackupFacade);
readonly activeSection = input.required<string>();
readonly isPwa = input(false);
readonly isRemovingAllPlaylists = input(false);
readonly isExportingData = input(false);
readonly includeSecrets = this.backupFacade.includeSecrets;
readonly importData = output<void>();
readonly exportData = output<void>();
@@ -1,12 +1,20 @@
import { inject, Injectable, signal } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
PlaylistBackupImportSummary,
PlaylistBackupService,
PlaylistBackupXtreamCredentials,
PlaylistBackupXtreamCredentialsRequest,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { firstValueFrom } from 'rxjs';
import {
SettingsBackupCredentialsDialogComponent,
SettingsBackupCredentialsDialogData,
} from './settings-backup-credentials-dialog.component';
import { SettingsSnackbarService } from './settings-snackbar.service';
@Injectable()
@@ -16,7 +24,9 @@ export class SettingsBackupFacade {
private readonly settingsSnackbar = inject(SettingsSnackbarService);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly matDialog = inject(MatDialog);
readonly includeSecrets = signal(false);
readonly isExportingData = signal(false);
async exportData(waitForUiFeedbackFrame: () => Promise<void>) {
@@ -28,7 +38,9 @@ export class SettingsBackupFacade {
await waitForUiFeedbackFrame();
try {
const backup = await this.playlistBackupService.exportBackup();
const backup = await this.playlistBackupService.exportBackup({
includeSecrets: this.includeSecrets(),
});
if (this.runtime.supportsDesktopFileSave && window.electron) {
const savePath = await window.electron.saveFileDialog(
@@ -77,7 +89,11 @@ export class SettingsBackupFacade {
try {
const summary = await this.playlistBackupService.importBackup(
await file.text()
await file.text(),
{
resolveXtreamCredentials: (request) =>
this.resolveXtreamCredentials(request),
}
);
if (summary.imported > 0 || summary.merged > 0) {
@@ -124,6 +140,36 @@ export class SettingsBackupFacade {
window.URL.revokeObjectURL(url);
}
private async resolveXtreamCredentials(
request: PlaylistBackupXtreamCredentialsRequest
): Promise<PlaylistBackupXtreamCredentials | null> {
const dialogRef = this.matDialog.open<
SettingsBackupCredentialsDialogComponent,
SettingsBackupCredentialsDialogData,
PlaylistBackupXtreamCredentials | null
>(SettingsBackupCredentialsDialogComponent, {
data: {
playlistTitle: request.title,
serverHost: this.getServerHost(request.serverUrl),
},
maxWidth: 'calc(100vw - 32px)',
width: '440px',
});
return (await firstValueFrom(dialogRef.afterClosed())) ?? null;
}
private getServerHost(serverUrl: string): string {
try {
const normalizedUrl = serverUrl.includes('://')
? serverUrl
: `https://${serverUrl}`;
return new URL(normalizedUrl).host;
} catch {
return '';
}
}
private buildBackupImportSummary(
summary: PlaylistBackupImportSummary
): string {
@@ -46,6 +46,8 @@ import {
} from '@iptvnator/shared/interfaces';
import { SettingsComponent } from './settings.component';
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
import { SettingsBackupCredentialsDialogComponent } from './settings-backup-credentials-dialog.component';
import { SettingsBackupSectionComponent } from './settings-backup-section.component';
import { signal } from '@angular/core';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
@@ -1192,9 +1194,125 @@ describe('SettingsComponent', () => {
'/tmp/backup.json',
'{}'
);
expect(playlistBackupService.exportBackup).toHaveBeenCalledWith({
includeSecrets: false,
});
expect(component.isExportingData()).toBe(false);
});
it('includes credentials only after the backup checkbox is enabled', async () => {
const backupSection = fixture.debugElement.query(
By.directive(SettingsBackupSectionComponent)
).componentInstance as SettingsBackupSectionComponent;
expect(backupSection.includeSecrets()).toBe(false);
backupSection.includeSecrets.set(true);
await component.exportData();
expect(playlistBackupService.exportBackup).toHaveBeenCalledWith({
includeSecrets: true,
});
});
it('shows both redacted-backup and secret-export guidance', () => {
const backupSection = fixture.debugElement.query(
By.directive(SettingsBackupSectionComponent)
);
expect(backupSection.nativeElement.textContent).toContain(
'SETTINGS.BACKUP_REDACTED_WARNING'
);
expect(backupSection.nativeElement.textContent).not.toContain(
'SETTINGS.BACKUP_SECRETS_WARNING'
);
(
backupSection.componentInstance as SettingsBackupSectionComponent
).includeSecrets.set(true);
fixture.detectChanges();
expect(backupSection.nativeElement.textContent).toContain(
'SETTINGS.BACKUP_SECRETS_WARNING'
);
});
it('resolves redacted Xtream credentials through the dialog and reports skipped entries', async () => {
const backupJson = JSON.stringify({
kind: 'iptvnator-playlist-backup',
version: 1,
exportedAt: '2026-04-21T00:00:00.000Z',
includeSecrets: false,
playlists: [],
});
const file = {
text: jest.fn().mockResolvedValue(backupJson),
} as unknown as File;
const inputClickSpy = jest
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(function (this: HTMLInputElement) {
Object.defineProperty(this, 'files', {
configurable: true,
value: [file],
});
this.dispatchEvent(new Event('change'));
});
const dialogOpenSpy = jest
.spyOn(privateApi(component).matDialog, 'open')
.mockReturnValue({
afterClosed: () => of(undefined),
} as unknown as ReturnType<MatDialog['open']>);
(
playlistBackupService.importBackup as jest.Mock
).mockImplementationOnce(async (_json, options) => {
const credentials = await options.resolveXtreamCredentials({
exportedId: 'xtream-redacted',
title: 'Living room',
serverUrl:
'https://viewer:secret@provider.example:8443/player_api.php?token=private',
});
expect(credentials).toBeNull();
return {
imported: 0,
merged: 0,
skipped: 1,
failed: 0,
errors: [],
};
});
try {
component.importData();
await fixture.whenStable();
expect(playlistBackupService.importBackup).toHaveBeenCalledWith(
backupJson,
{
resolveXtreamCredentials: expect.any(Function),
}
);
expect(dialogOpenSpy).toHaveBeenCalledWith(
SettingsBackupCredentialsDialogComponent,
expect.objectContaining({
data: {
playlistTitle: 'Living room',
serverHost: 'provider.example:8443',
},
})
);
expect(snackBar.open).toHaveBeenCalledWith(
expect.stringContaining('1 skipped'),
undefined,
expect.any(Object)
);
} finally {
dialogOpenSpy.mockRestore();
inputClickSpy.mockRestore();
}
});
it('falls back to browser backup download when desktop file-save preload is incomplete', async () => {
fixture.destroy();
const saveFileDialog = jest.fn().mockResolvedValue('/tmp/backup.json');
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "تصدير",
"EXPORTING_DATA": "جارٍ التصدير…",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "صدّر",
"EXPORTING_DATA": "جاري التصدير…",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "Экспарт",
"EXPORTING_DATA": "Экспартаванне...",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "Export",
"EXPORTING_DATA": "Wird exportiert …",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "Εξαγωγή",
"EXPORTING_DATA": "Εξαγωγή…",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "Export",
"EXPORTING_DATA": "Exporting…",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "Exportar",
"EXPORTING_DATA": "Exportando…",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "Exporter",
"EXPORTING_DATA": "Exportation…",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "Exportálás",
"EXPORTING_DATA": "Exportálás…",
"REMOVE_ALL": "Az összes lejátszási lista és kapcsolódó adat törlése",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Összes törlése",
"REMOVE_ALL_DESCRIPTION": "Az alkalmazásban tárolt összes M3U-, Xtream- és Stalker-lejátszási lista törlése.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "Esporta",
"EXPORTING_DATA": "Esportazione...",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "エクスポート",
"EXPORTING_DATA": "エクスポート中…",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "내보내다",
"EXPORTING_DATA": "내보내는 중…",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "Exporteren",
"EXPORTING_DATA": "Exporteren…",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "Eksportuj",
"EXPORTING_DATA": "Eksportowanie…",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "Exportar",
"EXPORTING_DATA": "Exportando…",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "Экспорт",
"EXPORTING_DATA": "Экспорт…",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "Dışa Aktar",
"EXPORTING_DATA": "Dışa aktarılıyor…",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "出口",
"EXPORTING_DATA": "导出中…",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {
+16
View File
@@ -354,6 +354,22 @@
"EXPORT_DATA": "匯出",
"EXPORTING_DATA": "正在匯出…",
"REMOVE_ALL": "Delete all playlists and playlist data",
"BACKUP_CONTENTS": "Backup contents",
"BACKUP_INCLUDE_SECRETS": "Include portal credentials and Stalker device identity",
"BACKUP_REDACTED_WARNING": "Portal passwords and Stalker device identity are excluded by default. Even a redacted backup can still contain MAC addresses, portal hosts/source URLs, and private URLs embedded in raw M3U playlist data.",
"BACKUP_SECRETS_WARNING": "Warning: this export will include portal usernames and passwords plus Stalker device identity fields. Store the backup securely.",
"BACKUP_CREDENTIALS_DIALOG": {
"TITLE": "Xtream credentials required",
"MESSAGE": "This redacted backup does not contain the credentials for this playlist.",
"PLAYLIST": "Playlist",
"SERVER": "Server",
"USERNAME": "Username",
"PASSWORD": "Password",
"USERNAME_REQUIRED": "Enter a username.",
"PASSWORD_REQUIRED": "Enter a password.",
"SKIP": "Skip playlist",
"IMPORT": "Import playlist"
},
"REMOVE_ALL_BUTTON": "Delete all",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"REMOVE_DIALOG": {